Merge pull request 'permission module base applied' (#69) from SQS_BROKER into main
Deploy to S3 / deploy (push) Successful in 37s
Details
Deploy to S3 / deploy (push) Successful in 37s
Details
Reviewed-on: #69pull/70/head^2
commit
0365ee3a8f
|
|
@ -6,7 +6,7 @@ from pydantic import BaseModel
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db_setup import get_session
|
||||
from org_settings.views import OrgSetting
|
||||
from org_settings.views import Exclusion, OrgSetting
|
||||
from users.permissions import PermissionTag, require_permission
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -52,3 +52,153 @@ async def update_org_settings(
|
|||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
class ExcludeUniversityCreate(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class ExcludeUniversityUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class ExcludeCompanyCreate(BaseModel):
|
||||
name: str
|
||||
linkedin_url: str | None = None
|
||||
|
||||
|
||||
class ExcludeCompanyUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
linkedin_url: str | None = None
|
||||
|
||||
|
||||
@router.get("/org-settings/exclude-university/fetch")
|
||||
async def fetch_exclude_universities(
|
||||
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = Exclusion(session=session)
|
||||
data, total = await service.get_universities()
|
||||
return JSONResponse(content={"data": data, "total": total, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/org-settings/exclude-university/create")
|
||||
async def create_exclude_university(
|
||||
payload: ExcludeUniversityCreate,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = Exclusion(session=session)
|
||||
data = await service.create_university(payload.model_dump(exclude_unset=True), current_user)
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/org-settings/exclude-university/update")
|
||||
async def update_exclude_university(
|
||||
payload: ExcludeUniversityUpdate,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)),
|
||||
record_id: str = Query(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = Exclusion(session=session)
|
||||
data = await service.update_university(
|
||||
record_id, payload.model_dump(exclude_unset=True), current_user
|
||||
)
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/org-settings/exclude-university/delete")
|
||||
async def delete_exclude_university(
|
||||
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)),
|
||||
record_id: str = Query(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = Exclusion(session=session)
|
||||
data = await service.delete_university(record_id, current_user)
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/org-settings/exclude-company/fetch")
|
||||
async def fetch_exclude_companies(
|
||||
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = Exclusion(session=session)
|
||||
data, total = await service.get_companies()
|
||||
return JSONResponse(content={"data": data, "total": total, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/org-settings/exclude-company/create")
|
||||
async def create_exclude_company(
|
||||
payload: ExcludeCompanyCreate,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = Exclusion(session=session)
|
||||
data = await service.create_company(payload.model_dump(exclude_unset=True), current_user)
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/org-settings/exclude-company/update")
|
||||
async def update_exclude_company(
|
||||
payload: ExcludeCompanyUpdate,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)),
|
||||
record_id: str = Query(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = Exclusion(session=session)
|
||||
data = await service.update_company(
|
||||
record_id, payload.model_dump(exclude_unset=True), current_user
|
||||
)
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/org-settings/exclude-company/delete")
|
||||
async def delete_exclude_company(
|
||||
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)),
|
||||
record_id: str = Query(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = Exclusion(session=session)
|
||||
data = await service.delete_company(record_id, current_user)
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from typing import Any
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, JSON
|
||||
from sqlalchemy import DateTime, JSON, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import Field, SQLModel, select
|
||||
|
||||
|
|
@ -76,4 +76,188 @@ class OrgSettings(SQLModel, table=True):
|
|||
return rows
|
||||
|
||||
|
||||
class ExcludeUniversity(SQLModel, table=True):
|
||||
__tablename__ = "Exclude_University"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
name: str = Field(index=True)
|
||||
created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
is_deleted: bool = Field(default=False)
|
||||
|
||||
@staticmethod
|
||||
def _as_uuid(record_id) -> uuid.UUID | None:
|
||||
if record_id in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(record_id))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def get_by_id(cls, session: AsyncSession, record_id):
|
||||
uid = cls._as_uuid(record_id)
|
||||
if uid is None:
|
||||
return None
|
||||
result = await session.execute(
|
||||
select(cls).where(cls.id == uid, cls.is_deleted == False) # noqa: E712
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def get_by_name(cls, session: AsyncSession, name: str, *, exclude_id=None):
|
||||
cleaned = (name or "").strip()
|
||||
if not cleaned:
|
||||
return None
|
||||
statement = select(cls).where(
|
||||
func.lower(cls.name) == cleaned.lower(),
|
||||
cls.is_deleted == False, # noqa: E712
|
||||
)
|
||||
if exclude_id is not None:
|
||||
uid = cls._as_uuid(exclude_id)
|
||||
if uid is not None:
|
||||
statement = statement.where(cls.id != uid)
|
||||
result = await session.execute(statement)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def fetch_rows(cls, session: AsyncSession):
|
||||
statement = select(cls).where(cls.is_deleted == False).order_by(cls.name.asc()) # noqa: E712
|
||||
result = await session.execute(statement)
|
||||
rows = list(result.scalars().all())
|
||||
return rows, len(rows)
|
||||
|
||||
@classmethod
|
||||
async def fetch_names(cls, session: AsyncSession) -> list[str]:
|
||||
rows, _ = await cls.fetch_rows(session)
|
||||
return [r.name for r in rows if r.name]
|
||||
|
||||
@classmethod
|
||||
async def insert_row(cls, session: AsyncSession, fields: dict):
|
||||
row = cls(**fields)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return await cls.get_by_id(session, row.id)
|
||||
|
||||
@classmethod
|
||||
async def update_row(cls, session: AsyncSession, record_id, fields: dict):
|
||||
row = await cls.get_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
for key, value in fields.items():
|
||||
setattr(row, key, value)
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
async def soft_delete_row(cls, session: AsyncSession, record_id):
|
||||
row = await cls.get_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
row.is_deleted = True
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return row
|
||||
|
||||
|
||||
class ExcludeCompany(SQLModel, table=True):
|
||||
__tablename__ = "Exclude_Company"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
name: str = Field(index=True)
|
||||
linkedin_url: str | None = Field(default=None)
|
||||
created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
is_deleted: bool = Field(default=False)
|
||||
|
||||
@staticmethod
|
||||
def _as_uuid(record_id) -> uuid.UUID | None:
|
||||
if record_id in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(record_id))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def get_by_id(cls, session: AsyncSession, record_id):
|
||||
uid = cls._as_uuid(record_id)
|
||||
if uid is None:
|
||||
return None
|
||||
result = await session.execute(
|
||||
select(cls).where(cls.id == uid, cls.is_deleted == False) # noqa: E712
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def get_by_name(cls, session: AsyncSession, name: str, *, exclude_id=None):
|
||||
cleaned = (name or "").strip()
|
||||
if not cleaned:
|
||||
return None
|
||||
statement = select(cls).where(
|
||||
func.lower(cls.name) == cleaned.lower(),
|
||||
cls.is_deleted == False, # noqa: E712
|
||||
)
|
||||
if exclude_id is not None:
|
||||
uid = cls._as_uuid(exclude_id)
|
||||
if uid is not None:
|
||||
statement = statement.where(cls.id != uid)
|
||||
result = await session.execute(statement)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def fetch_rows(cls, session: AsyncSession):
|
||||
statement = select(cls).where(cls.is_deleted == False).order_by(cls.name.asc()) # noqa: E712
|
||||
result = await session.execute(statement)
|
||||
rows = list(result.scalars().all())
|
||||
return rows, len(rows)
|
||||
|
||||
@classmethod
|
||||
async def fetch_names(cls, session: AsyncSession) -> list[str]:
|
||||
rows, _ = await cls.fetch_rows(session)
|
||||
return [r.name for r in rows if r.name]
|
||||
|
||||
@classmethod
|
||||
async def fetch_linkedin_urls(cls, session: AsyncSession) -> list[str]:
|
||||
rows, _ = await cls.fetch_rows(session)
|
||||
return [r.linkedin_url for r in rows if r.linkedin_url]
|
||||
|
||||
@classmethod
|
||||
async def insert_row(cls, session: AsyncSession, fields: dict):
|
||||
row = cls(**fields)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return await cls.get_by_id(session, row.id)
|
||||
|
||||
@classmethod
|
||||
async def update_row(cls, session: AsyncSession, record_id, fields: dict):
|
||||
row = await cls.get_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
for key, value in fields.items():
|
||||
setattr(row, key, value)
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
async def soft_delete_row(cls, session: AsyncSession, record_id):
|
||||
row = await cls.get_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
row.is_deleted = True
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return row
|
||||
|
||||
|
||||
import users.models as _users_models # noqa: E402, F401
|
||||
|
|
|
|||
|
|
@ -5,3 +5,22 @@ def serialize_org_setting(row) -> dict:
|
|||
"category": row.category,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def serialize_exclude_university(row) -> dict:
|
||||
return {
|
||||
"id": str(row.id) if row.id else None,
|
||||
"name": row.name,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def serialize_exclude_company(row) -> dict:
|
||||
return {
|
||||
"id": str(row.id) if row.id else None,
|
||||
"name": row.name,
|
||||
"linkedin_url": row.linkedin_url,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,12 @@ import uuid
|
|||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from org_settings.models import OrgSettings
|
||||
from org_settings.serializers import serialize_org_setting
|
||||
from org_settings.models import ExcludeCompany, ExcludeUniversity, OrgSettings
|
||||
from org_settings.serializers import (
|
||||
serialize_exclude_company,
|
||||
serialize_exclude_university,
|
||||
serialize_org_setting,
|
||||
)
|
||||
|
||||
VALID_CATEGORIES = (
|
||||
"general",
|
||||
|
|
@ -70,3 +74,111 @@ class OrgSetting:
|
|||
})
|
||||
rows = await OrgSettings.upsert_settings(self.session, cleaned, _user_id(current_user))
|
||||
return [serialize_org_setting(r) for r in rows]
|
||||
|
||||
|
||||
MAX_NAME_LEN = 200
|
||||
MAX_URL_LEN = 500
|
||||
|
||||
|
||||
def _clean_name(value, *, label="name"):
|
||||
name = (value or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=422, detail=f"{label} is required")
|
||||
if len(name) > MAX_NAME_LEN:
|
||||
raise HTTPException(status_code=422, detail=f"{label} must be {MAX_NAME_LEN} characters or fewer")
|
||||
return name
|
||||
|
||||
|
||||
def _clean_linkedin_url(value):
|
||||
url = (value or "").strip() or None
|
||||
if url is None:
|
||||
return None
|
||||
if len(url) > MAX_URL_LEN:
|
||||
raise HTTPException(status_code=422, detail=f"linkedin_url must be {MAX_URL_LEN} characters or fewer")
|
||||
lowered = url.lower()
|
||||
if not (lowered.startswith("http://") or lowered.startswith("https://")):
|
||||
raise HTTPException(status_code=422, detail="linkedin_url must be an http(s) URL")
|
||||
return url
|
||||
|
||||
|
||||
class Exclusion:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def get_universities(self):
|
||||
rows, total = await ExcludeUniversity.fetch_rows(self.session)
|
||||
return [serialize_exclude_university(r) for r in rows], total
|
||||
|
||||
async def create_university(self, payload, current_user):
|
||||
name = _clean_name(payload.get("name"), label="name")
|
||||
if await ExcludeUniversity.get_by_name(self.session, name):
|
||||
raise HTTPException(status_code=409, detail="That university is already excluded")
|
||||
row = await ExcludeUniversity.insert_row(self.session, {
|
||||
"name": name,
|
||||
"created_by": _user_id(current_user),
|
||||
})
|
||||
return serialize_exclude_university(row)
|
||||
|
||||
async def update_university(self, record_id, payload, current_user):
|
||||
_user_id(current_user)
|
||||
fields = {}
|
||||
if "name" in payload:
|
||||
name = _clean_name(payload.get("name"), label="name")
|
||||
existing = await ExcludeUniversity.get_by_name(self.session, name, exclude_id=record_id)
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="That university is already excluded")
|
||||
fields["name"] = name
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
row = await ExcludeUniversity.update_row(self.session, record_id, fields)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Excluded university not found")
|
||||
return serialize_exclude_university(row)
|
||||
|
||||
async def delete_university(self, record_id, current_user):
|
||||
_user_id(current_user)
|
||||
row = await ExcludeUniversity.soft_delete_row(self.session, record_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Excluded university not found")
|
||||
return {"id": str(row.id), "deleted": True}
|
||||
|
||||
async def get_companies(self):
|
||||
rows, total = await ExcludeCompany.fetch_rows(self.session)
|
||||
return [serialize_exclude_company(r) for r in rows], total
|
||||
|
||||
async def create_company(self, payload, current_user):
|
||||
name = _clean_name(payload.get("name"), label="name")
|
||||
linkedin_url = _clean_linkedin_url(payload.get("linkedin_url"))
|
||||
if await ExcludeCompany.get_by_name(self.session, name):
|
||||
raise HTTPException(status_code=409, detail="That company is already excluded")
|
||||
row = await ExcludeCompany.insert_row(self.session, {
|
||||
"name": name,
|
||||
"linkedin_url": linkedin_url,
|
||||
"created_by": _user_id(current_user),
|
||||
})
|
||||
return serialize_exclude_company(row)
|
||||
|
||||
async def update_company(self, record_id, payload, current_user):
|
||||
_user_id(current_user)
|
||||
fields = {}
|
||||
if "name" in payload:
|
||||
name = _clean_name(payload.get("name"), label="name")
|
||||
existing = await ExcludeCompany.get_by_name(self.session, name, exclude_id=record_id)
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="That company is already excluded")
|
||||
fields["name"] = name
|
||||
if "linkedin_url" in payload:
|
||||
fields["linkedin_url"] = _clean_linkedin_url(payload.get("linkedin_url"))
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
row = await ExcludeCompany.update_row(self.session, record_id, fields)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Excluded company not found")
|
||||
return serialize_exclude_company(row)
|
||||
|
||||
async def delete_company(self, record_id, current_user):
|
||||
_user_id(current_user)
|
||||
row = await ExcludeCompany.soft_delete_row(self.session, record_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Excluded company not found")
|
||||
return {"id": str(row.id), "deleted": True}
|
||||
|
|
|
|||
|
|
@ -35,41 +35,32 @@ APIFY_TIMEOUT = float(os.getenv("APIFY_TIMEOUT", "30"))
|
|||
APIFY_MAX_COST_USD = float(os.getenv("APIFY_MAX_COST_USD", "1.0"))
|
||||
|
||||
|
||||
def _csv_env(name: str, default: str) -> list[str]:
|
||||
return [s.strip() for s in os.getenv(name, default).split(",") if s.strip()]
|
||||
|
||||
|
||||
# The user's own companies: their CURRENT employees must never appear in sourced
|
||||
# results. Names drive the always-on server-side filter (case-insensitive
|
||||
# substring, so "Utopia Brands Pakistan" matches too). URLs drive the actor's
|
||||
# excludeCurrentCompanies filter, which wants full LinkedIn company URLs and
|
||||
# stops those profiles from being scraped (and paid for) at all.
|
||||
APIFY_EXCLUDE_COMPANIES = _csv_env("APIFY_EXCLUDE_COMPANIES", "Utopia Brands,Utopia Deals")
|
||||
APIFY_EXCLUDE_COMPANY_URLS = _csv_env(
|
||||
"APIFY_EXCLUDE_COMPANY_URLS",
|
||||
"https://www.linkedin.com/company/utopiadeals,"
|
||||
"https://www.linkedin.com/company/utopia-brands-usa,"
|
||||
"https://www.linkedin.com/company/utopiabrands",
|
||||
)
|
||||
|
||||
|
||||
def _matches_excluded(text) -> bool:
|
||||
def _matches_excluded(text, names) -> bool:
|
||||
haystack = " ".join(str(text or "").lower().split())
|
||||
return bool(haystack) and any(
|
||||
name.lower() in haystack for name in APIFY_EXCLUDE_COMPANIES
|
||||
)
|
||||
if not haystack:
|
||||
return False
|
||||
return any(str(name).lower() in haystack for name in (names or []) if name)
|
||||
|
||||
|
||||
def is_excluded_profile(profile: dict) -> bool:
|
||||
"""True when the person currently works at one of the excluded companies.
|
||||
def is_excluded_profile(profile: dict, *, companies=None, universities=None) -> bool:
|
||||
"""True when the person matches a configured company or university exclusion.
|
||||
|
||||
The headline is only consulted when no current company was extracted, so an
|
||||
"ex-Utopia" headline on someone now elsewhere does not exclude them.
|
||||
Company: current employer, or headline only when no company was extracted so
|
||||
an "ex-…" headline on someone now elsewhere does not exclude them.
|
||||
University: any education school name on the sourced profile.
|
||||
Lists come from Exclude_Company / Exclude_University — never hardcoded.
|
||||
"""
|
||||
company = (profile or {}).get("current_company")
|
||||
if _matches_excluded(company):
|
||||
if _matches_excluded(company, companies):
|
||||
return True
|
||||
return not company and _matches_excluded((profile or {}).get("headline"))
|
||||
if not company and _matches_excluded((profile or {}).get("headline"), companies):
|
||||
return True
|
||||
if universities:
|
||||
raw = (profile or {}).get("raw") or {}
|
||||
for edu in extract_education(raw):
|
||||
if _matches_excluded(edu.get("school"), universities):
|
||||
return True
|
||||
return False
|
||||
|
||||
# Apify run status -> talent_runs.status. Transitional states stay "running";
|
||||
# unknown values also stay "running" so we never commit a terminal state we
|
||||
|
|
@ -189,7 +180,12 @@ def _skill_terms(*entry_lists) -> list[str]:
|
|||
|
||||
|
||||
def build_actor_input(
|
||||
job: dict, *, max_results: int, overrides: dict | None = None, start_page: int = 1
|
||||
job: dict,
|
||||
*,
|
||||
max_results: int,
|
||||
overrides: dict | None = None,
|
||||
start_page: int = 1,
|
||||
exclude_company_urls: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Deterministic actor input from job fields. No LLM involved.
|
||||
|
||||
|
|
@ -225,8 +221,9 @@ def build_actor_input(
|
|||
)
|
||||
if experience_ids:
|
||||
actor_input["yearsOfExperienceIds"] = experience_ids
|
||||
if APIFY_EXCLUDE_COMPANY_URLS:
|
||||
actor_input["excludeCurrentCompanies"] = APIFY_EXCLUDE_COMPANY_URLS
|
||||
urls = [u.strip() for u in (exclude_company_urls or []) if u and str(u).strip()]
|
||||
if urls:
|
||||
actor_input["excludeCurrentCompanies"] = urls
|
||||
if start_page and int(start_page) > 1:
|
||||
actor_input["startPage"] = min(int(start_page), 100)
|
||||
if "location" in overrides and overrides["location"] is not None:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from fastapi import HTTPException
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from job.job_post.models import JobPosts
|
||||
from org_settings.models import ExcludeCompany, ExcludeUniversity
|
||||
from talent import plugins
|
||||
from talent.enums import OutreachStatus
|
||||
from talent.matching import annotate_applications
|
||||
|
|
@ -24,6 +25,13 @@ class Talent:
|
|||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def _exclusion_lists(self):
|
||||
"""Names and LinkedIn URLs from Settings — empty lists mean exclude nothing."""
|
||||
company_names = await ExcludeCompany.fetch_names(self.session)
|
||||
company_urls = await ExcludeCompany.fetch_linkedin_urls(self.session)
|
||||
university_names = await ExcludeUniversity.fetch_names(self.session)
|
||||
return company_names, company_urls, university_names
|
||||
|
||||
async def _get_job(self, job_post_id):
|
||||
job = await JobPosts.get_job_post_by_id(self.session, job_post_id)
|
||||
if not job or job.is_deleted:
|
||||
|
|
@ -60,8 +68,12 @@ class Talent:
|
|||
"experience_min": job.experience_min,
|
||||
"experience_max": job.experience_max,
|
||||
}
|
||||
_company_names, company_urls, _university_names = await self._exclusion_lists()
|
||||
actor_input = plugins.build_actor_input(
|
||||
job_fields, max_results=max_results, overrides=overrides
|
||||
job_fields,
|
||||
max_results=max_results,
|
||||
overrides=overrides,
|
||||
exclude_company_urls=company_urls,
|
||||
)
|
||||
|
||||
# Re-running the same search continues deeper into LinkedIn's result
|
||||
|
|
@ -80,6 +92,7 @@ class Talent:
|
|||
max_results=max_results,
|
||||
overrides=overrides,
|
||||
start_page=max(prior_pages) + 1,
|
||||
exclude_company_urls=company_urls,
|
||||
)
|
||||
run = await TalentRuns.insert_run(self.session, {
|
||||
"job_post_id": job.id,
|
||||
|
|
@ -149,10 +162,13 @@ class Talent:
|
|||
items = await plugins.get_dataset_items(dataset_id, limit=run.max_results)
|
||||
except (httpx.HTTPError, plugins.ApifyError, RuntimeError) as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Apify dataset fetch failed: {exc}")
|
||||
company_names, _company_urls, university_names = await self._exclusion_lists()
|
||||
normalized = [
|
||||
p
|
||||
for p in (plugins.normalize_profile(i) for i in items)
|
||||
if p and not plugins.is_excluded_profile(p)
|
||||
if p and not plugins.is_excluded_profile(
|
||||
p, companies=company_names, universities=university_names
|
||||
)
|
||||
]
|
||||
job = await JobPosts.get_job_post_by_id(self.session, run.job_post_id)
|
||||
if job:
|
||||
|
|
|
|||
|
|
@ -10,6 +10,52 @@ export function update(settings) {
|
|||
return request('/org-settings/update', { method: 'PUT', body: { settings } })
|
||||
}
|
||||
|
||||
export function listUniversities() {
|
||||
return request('/org-settings/exclude-university/fetch')
|
||||
}
|
||||
|
||||
export function createUniversity(body) {
|
||||
return request('/org-settings/exclude-university/create', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function updateUniversity(recordId, body) {
|
||||
return request('/org-settings/exclude-university/update', {
|
||||
method: 'PATCH',
|
||||
params: { record_id: recordId },
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function removeUniversity(recordId) {
|
||||
return request('/org-settings/exclude-university/delete', {
|
||||
method: 'DELETE',
|
||||
params: { record_id: recordId },
|
||||
})
|
||||
}
|
||||
|
||||
export function listCompanies() {
|
||||
return request('/org-settings/exclude-company/fetch')
|
||||
}
|
||||
|
||||
export function createCompany(body) {
|
||||
return request('/org-settings/exclude-company/create', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function updateCompany(recordId, body) {
|
||||
return request('/org-settings/exclude-company/update', {
|
||||
method: 'PATCH',
|
||||
params: { record_id: recordId },
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function removeCompany(recordId) {
|
||||
return request('/org-settings/exclude-company/delete', {
|
||||
method: 'DELETE',
|
||||
params: { record_id: recordId },
|
||||
})
|
||||
}
|
||||
|
||||
/** Flatten `{data:[{key,value,category}]}` into a key → value map. */
|
||||
export function toMap(res) {
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ export const qk = {
|
|||
orgSettings: {
|
||||
all: () => ['orgSettings'],
|
||||
list: (p = {}) => ['orgSettings', 'list', p],
|
||||
universities: () => ['orgSettings', 'exclude-universities'],
|
||||
companies: () => ['orgSettings', 'exclude-companies'],
|
||||
},
|
||||
savedSearches: {
|
||||
all: () => ['savedSearches'],
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
/* ============================================================
|
||||
Settings — org settings tabs persist via GET/PUT /org-settings/*.
|
||||
Users + Appearance stay as before. Email Templates stay decorative
|
||||
(explicitly out of Section C wiring scope). The Permissions tab remains
|
||||
chrome; Access Control is the authoritative RBAC surface.
|
||||
Excluding Universities / Companies are CRUD lists on Exclude_University
|
||||
and Exclude_Company (not org_settings key/value). Users + Appearance stay
|
||||
as before. Email Templates stay decorative (explicitly out of Section C
|
||||
wiring scope). The Permissions tab remains chrome; Access Control is the
|
||||
authoritative RBAC surface.
|
||||
============================================================ */
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
|
@ -35,6 +37,7 @@ function humaniseSlug(slug) {
|
|||
const TABS = [
|
||||
'General', 'Users', 'Approvals', 'Permissions', 'Notifications',
|
||||
'Email Templates', 'Career Portal', 'Branding', 'Security', 'Appearance',
|
||||
'Excluding Universities', 'Excluding Companies',
|
||||
]
|
||||
|
||||
const ORG_TABS = new Set(['General', 'Notifications', 'Career Portal', 'Branding', 'Security'])
|
||||
|
|
@ -140,6 +143,7 @@ export default function Settings() {
|
|||
<Tabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
className="tabs tabs-wrap"
|
||||
tabs={TABS.map((t) => ({
|
||||
key: t,
|
||||
label: t,
|
||||
|
|
@ -158,6 +162,8 @@ export default function Settings() {
|
|||
{tab === 'Branding' && <Branding registerSave={(fn) => { saveRef.current = fn }} />}
|
||||
{tab === 'Security' && <Security registerSave={(fn) => { saveRef.current = fn }} />}
|
||||
{tab === 'Appearance' && <Appearance />}
|
||||
{tab === 'Excluding Universities' && <ExcludeUniversities />}
|
||||
{tab === 'Excluding Companies' && <ExcludeCompanies />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -1219,3 +1225,207 @@ function Appearance() {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExcludeUniversities() {
|
||||
return (
|
||||
<ExclusionList
|
||||
title="Excluded Universities"
|
||||
subtitle="Talent sourcing skips profiles whose education matches a name on this list"
|
||||
emptyTitle="No universities excluded"
|
||||
emptyBody="Add a university name. Matching is case-insensitive and substring-based, so “NUST” also matches “NUST Islamabad”."
|
||||
nameLabel="University name"
|
||||
namePlaceholder="e.g. National University of Sciences and Technology"
|
||||
queryKey={qk.orgSettings.universities()}
|
||||
listFn={orgSettingsApi.listUniversities}
|
||||
createFn={(name) => orgSettingsApi.createUniversity({ name })}
|
||||
removeFn={orgSettingsApi.removeUniversity}
|
||||
addedNoun="university"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ExcludeCompanies() {
|
||||
return (
|
||||
<ExclusionList
|
||||
title="Excluded Companies"
|
||||
subtitle="Talent sourcing skips people currently at these companies. Optional LinkedIn URLs stop those profiles being scraped at all."
|
||||
emptyTitle="No companies excluded"
|
||||
emptyBody="Add a company name. Matching is case-insensitive and substring-based, so “Utopia Brands” also matches “Utopia Brands Pakistan”."
|
||||
nameLabel="Company name"
|
||||
namePlaceholder="e.g. Utopia Brands"
|
||||
extraLabel="LinkedIn company URL"
|
||||
extraPlaceholder="https://www.linkedin.com/company/…"
|
||||
extraKey="linkedin_url"
|
||||
extraHeader="LinkedIn URL"
|
||||
queryKey={qk.orgSettings.companies()}
|
||||
listFn={orgSettingsApi.listCompanies}
|
||||
createFn={(name, extra) => orgSettingsApi.createCompany({ name, linkedin_url: extra || null })}
|
||||
removeFn={orgSettingsApi.removeCompany}
|
||||
addedNoun="company"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ExclusionList({
|
||||
title, subtitle, emptyTitle, emptyBody,
|
||||
nameLabel, namePlaceholder,
|
||||
extraLabel, extraPlaceholder, extraKey, extraHeader,
|
||||
queryKey, listFn, createFn, removeFn, addedNoun,
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
const { can } = usePermission()
|
||||
const qc = useQueryClient()
|
||||
const canConfigure = can('settings.configure')
|
||||
const [name, setName] = useState('')
|
||||
const [extra, setExtra] = useState('')
|
||||
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => listFn().then((r) => r.data ?? []),
|
||||
})
|
||||
const rows = query.data ?? []
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () => createFn(name.trim(), extra.trim()),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey })
|
||||
setName('')
|
||||
setExtra('')
|
||||
toast(`${addedNoun[0].toUpperCase()}${addedNoun.slice(1)} excluded`, 'success')
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, `Could not add this ${addedNoun}.`), 'error'),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id) => removeFn(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey })
|
||||
toast(`Removed from exclusion list`, 'success')
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, `Could not remove this ${addedNoun}.`), 'error'),
|
||||
})
|
||||
|
||||
function submit() {
|
||||
if (!name.trim() || create.isPending) return
|
||||
create.mutate()
|
||||
}
|
||||
|
||||
if (query.isPending) {
|
||||
return (
|
||||
<div className="card"><div className="card-body">
|
||||
<EmptyState icon="settings" title="Loading…">Fetching the exclusion list.</EmptyState>
|
||||
</div></div>
|
||||
)
|
||||
}
|
||||
if (query.isError) {
|
||||
return (
|
||||
<div className="card"><div className="card-body">
|
||||
<EmptyState icon="settings" title="Couldn’t load exclusions">
|
||||
{friendlyAuthError(query.error, 'This tab needs settings.view.')}
|
||||
</EmptyState>
|
||||
</div></div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<h3>{title}</h3>
|
||||
<span className="ch-sub">{subtitle}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<form
|
||||
noValidate
|
||||
onSubmit={(e) => { e.preventDefault(); submit() }}
|
||||
style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}
|
||||
>
|
||||
<div className="form-field" style={{ flex: '1 1 220px' }}>
|
||||
<label htmlFor={`ex-${addedNoun}-name`}>{nameLabel}</label>
|
||||
<input
|
||||
id={`ex-${addedNoun}-name`}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={namePlaceholder}
|
||||
disabled={!canConfigure || create.isPending}
|
||||
/>
|
||||
</div>
|
||||
{extraKey && (
|
||||
<div className="form-field" style={{ flex: '1 1 260px' }}>
|
||||
<label htmlFor={`ex-${addedNoun}-extra`}>{extraLabel}</label>
|
||||
<input
|
||||
id={`ex-${addedNoun}-extra`}
|
||||
type="url"
|
||||
value={extra}
|
||||
onChange={(e) => setExtra(e.target.value)}
|
||||
placeholder={extraPlaceholder}
|
||||
disabled={!canConfigure || create.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-field">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={!canConfigure || create.isPending || !name.trim()}
|
||||
>
|
||||
<Icon name="plus" /> {create.isPending ? 'Adding…' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{!canConfigure && (
|
||||
<div className="alert alert-danger" style={{ marginTop: 14 }}>
|
||||
Adding or removing entries requires <code>settings.configure</code>.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="card-body" style={{ paddingTop: 0 }}>
|
||||
<EmptyState icon="settings" title={emptyTitle}>{emptyBody}</EmptyState>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{nameLabel}</th>
|
||||
{extraHeader && <th>{extraHeader}</th>}
|
||||
<th>Added</th>
|
||||
<th style={{ textAlign: 'right' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<div className="cell-primary">{row.name}</div>
|
||||
</td>
|
||||
{extraHeader && (
|
||||
<td className="text-muted">
|
||||
{row[extraKey] ? (
|
||||
<a href={row[extraKey]} target="_blank" rel="noreferrer">{row[extraKey]}</a>
|
||||
) : '—'}
|
||||
</td>
|
||||
)}
|
||||
<td className="text-muted">{row.created_at ? fmtDate(row.created_at) : '—'}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<button
|
||||
className="act-btn danger"
|
||||
aria-label={`Remove ${row.name}`}
|
||||
disabled={!canConfigure || (remove.isPending && remove.variables === row.id)}
|
||||
onClick={() => remove.mutate(row.id)}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue