Scope monitoring PF objects to Telegram-linked users

This commit is contained in:
Grendgi
2026-06-05 10:06:28 +03:00
parent 8bdac8b15b
commit ccfb261e7f
6 changed files with 213 additions and 79 deletions

View File

@@ -31,12 +31,20 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
logger = logging.getLogger(__name__)
async def cmd_start(update: Update, _: ContextTypes.DEFAULT_TYPE) -> None:
def _portal_user_code(context: ContextTypes.DEFAULT_TYPE) -> str | None:
if not context.args:
return None
code = context.args[0].strip()
return code or None
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not update.effective_user or not update.effective_chat:
return
user = update.effective_user
chat_id = str(update.effective_chat.id)
username = user.username
portal_user_id = _portal_user_code(context)
db = SessionLocal()
try:
@@ -44,6 +52,21 @@ async def cmd_start(update: Update, _: ContextTypes.DEFAULT_TYPE) -> None:
db.query(Employee).filter(Employee.tg_chat_id == chat_id).first()
)
if existing:
if portal_user_id and existing.portal_user_id and existing.portal_user_id != portal_user_id:
await update.message.reply_text(
"Этот Telegram уже подключен к другому пользователю Portal.",
)
return
if portal_user_id and not existing.portal_user_id:
clash = db.query(Employee).filter(Employee.portal_user_id == portal_user_id).first()
if clash and clash.id != existing.id:
await update.message.reply_text(
"Этот пользователь Portal уже подключен к другому Telegram.",
)
return
existing.portal_user_id = portal_user_id
existing.tg_username = username
db.commit()
await update.message.reply_text(
f"✅ Вы уже подключены как <b>{existing.name}</b>.\n"
f"chat_id: <code>{chat_id}</code>",
@@ -51,6 +74,34 @@ async def cmd_start(update: Update, _: ContextTypes.DEFAULT_TYPE) -> None:
)
return
if portal_user_id:
employee = db.query(Employee).filter(Employee.portal_user_id == portal_user_id).first()
name = (user.full_name or username or f"user_{chat_id}").strip()
if employee:
if employee.tg_chat_id and employee.tg_chat_id != chat_id:
await update.message.reply_text(
"Этот пользователь Portal уже подключен к другому Telegram.",
)
return
employee.name = employee.name or name
employee.tg_chat_id = chat_id
employee.tg_username = username
else:
employee = Employee(
name=name,
portal_user_id=portal_user_id,
tg_chat_id=chat_id,
tg_username=username,
)
db.add(employee)
db.commit()
await update.message.reply_text(
f"✅ Привет, <b>{name}</b>! Telegram подключен к вашему аккаунту Portal.\n"
f"Теперь можно добавлять объекты мониторинга в Portal.",
parse_mode="HTML",
)
return
# Try to find by username (admin pre-created employee w/o chat_id)
if username:
placeholder = (
@@ -68,15 +119,10 @@ async def cmd_start(update: Update, _: ContextTypes.DEFAULT_TYPE) -> None:
)
return
# Create a new employee record from this user
name = (user.full_name or username or f"user_{chat_id}").strip()
e = Employee(name=name, tg_chat_id=chat_id, tg_username=username)
db.add(e)
db.commit()
await update.message.reply_text(
f"👋 Привет, <b>{name}</b>! Вы зарегистрированы как сотрудник.\n"
f"Откройте веб-интерфейс и создайте проекты, чтобы получать уведомления.\n"
f"chat_id: <code>{chat_id}</code>",
"Откройте Portal → Мониторинг PF и нажмите подключение Telegram.\n"
"Бот должен получить команду вида:\n"
"<code>/start ваш_код_из_Portal</code>",
parse_mode="HTML",
)
finally:

View File

@@ -34,6 +34,7 @@ class Settings(BaseSettings):
scrape_interval_hours: int = 4
database_url: str = f"sqlite:///{DATA_DIR / 'monitor.db'}"
admin_chat_id: str = ""
tg_bot_username: str = ""
def model_post_init(self, __context) -> None:
self.database_url = _resolve_sqlite_url(self.database_url)

View File

@@ -1,4 +1,4 @@
from sqlalchemy import create_engine
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.config import settings
@@ -29,3 +29,17 @@ def init_db():
from app import models # noqa: F401 — registers models on Base
Base.metadata.create_all(bind=engine)
_migrate_employees_portal_user_id()
def _migrate_employees_portal_user_id() -> None:
inspector = inspect(engine)
if "employees" not in inspector.get_table_names():
return
columns = {col["name"] for col in inspector.get_columns("employees")}
with engine.begin() as conn:
if "portal_user_id" not in columns:
conn.execute(text("ALTER TABLE employees ADD COLUMN portal_user_id VARCHAR(100)"))
conn.execute(
text("CREATE UNIQUE INDEX IF NOT EXISTS ix_employees_portal_user_id ON employees (portal_user_id)")
)

View File

@@ -27,6 +27,7 @@ class Employee(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(200))
portal_user_id: Mapped[str | None] = mapped_column(String(100), unique=True, index=True, nullable=True)
tg_chat_id: Mapped[str | None] = mapped_column(String(64), unique=True, nullable=True)
tg_username: Mapped[str | None] = mapped_column(String(200), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

View File

@@ -30,6 +30,7 @@ app = FastAPI(title="Monitoring PF")
class EmployeeCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
portal_user_id: str | None = Field(None, max_length=100)
tg_username: str | None = Field(None, max_length=200)
tg_chat_id: str | None = Field(None, max_length=64)
@@ -43,7 +44,7 @@ class EmployeeUpdate(BaseModel):
class ProjectCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=300)
deal_type: DealType
owner_id: int
owner_id: int | None = None
our_price: float | None = None
notes: str | None = None
dld_permit: str | None = Field(None, max_length=100)
@@ -103,6 +104,49 @@ def _clean(value: str | None) -> str | None:
return value or None
def _portal_user_id(request: Request) -> str | None:
return _clean(request.headers.get("x-user-id"))
def _telegram_start_command(portal_user_id: str | None) -> str | None:
return f"/start {portal_user_id}" if portal_user_id else None
def _telegram_start_link(portal_user_id: str | None) -> str | None:
username = settings.tg_bot_username.strip().lstrip("@")
if not username or not portal_user_id:
return None
return f"https://t.me/{username}?start={portal_user_id}"
def _current_employee(request: Request, db: Session, *, required: bool = True) -> Employee | None:
portal_user_id = _portal_user_id(request)
if not portal_user_id:
if required:
raise HTTPException(status_code=401, detail="portal user is not available")
return None
employee = db.query(Employee).filter(Employee.portal_user_id == portal_user_id).first()
if (not employee or not employee.tg_chat_id) and required:
raise HTTPException(status_code=403, detail="Сначала авторизуйтесь в Telegram-боте Monitoring PF")
return employee
def _owned_project(request: Request, db: Session, project_id: int, *, with_detail: bool = False) -> Project:
employee = _current_employee(request, db)
query = db.query(Project).filter(Project.id == project_id, Project.owner_id == employee.id)
if with_detail:
query = query.options(
joinedload(Project.owner),
joinedload(Project.listings).joinedload(CompetitorListing.price_history),
)
else:
query = query.options(joinedload(Project.owner), joinedload(Project.listings))
project = query.first()
if not project:
raise HTTPException(404, "project not found")
return project
def _dt(value: datetime | None) -> str | None:
return value.isoformat() if value else None
@@ -111,6 +155,7 @@ def _employee_out(employee: Employee) -> dict[str, Any]:
return {
"id": employee.id,
"name": employee.name,
"portal_user_id": employee.portal_user_id,
"tg_chat_id": employee.tg_chat_id,
"tg_username": employee.tg_username,
"projects_total": len(employee.projects or []),
@@ -197,15 +242,41 @@ def _suggestion_out(item: Any) -> dict[str, Any]:
@app.get("/api/v1/access/me")
def access_me(request: Request) -> dict[str, Any]:
return {"is_admin": _is_admin(request)}
def access_me(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
portal_user_id = _portal_user_id(request)
employee = _current_employee(request, db, required=False)
return {
"is_admin": _is_admin(request),
"portal_user_id": portal_user_id,
"telegram_linked": bool(employee and employee.tg_chat_id),
"employee": _employee_out(employee) if employee else None,
"telegram_bot_username": settings.tg_bot_username.strip().lstrip("@") or None,
"telegram_start_command": _telegram_start_command(portal_user_id),
"telegram_start_link": _telegram_start_link(portal_user_id),
}
@app.get("/api/v1/summary")
def summary(db: Session = Depends(get_db)) -> dict[str, Any]:
projects = db.query(Project).options(joinedload(Project.listings)).all()
employees = db.query(Employee).all()
listings = db.query(CompetitorListing).all()
def summary(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
employee = _current_employee(request, db, required=False)
if not employee or not employee.tg_chat_id:
projects = []
employees = []
listings = []
else:
projects = (
db.query(Project)
.options(joinedload(Project.listings))
.filter(Project.owner_id == employee.id)
.all()
)
employees = [employee]
listings = (
db.query(CompetitorListing)
.join(Project)
.filter(Project.owner_id == employee.id)
.all()
)
active = [l for l in listings if l.status == ListingStatus.ACTIVE]
return {
"projects_total": len(projects),
@@ -219,20 +290,30 @@ def summary(db: Session = Depends(get_db)) -> dict[str, Any]:
@app.get("/api/v1/employees")
def employees_list(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
employees = (
db.query(Employee)
.options(joinedload(Employee.projects))
.order_by(Employee.name)
.all()
)
def employees_list(request: Request, db: Session = Depends(get_db)) -> list[dict[str, Any]]:
if _is_admin(request):
employees = (
db.query(Employee)
.options(joinedload(Employee.projects))
.order_by(Employee.name)
.all()
)
else:
employee = _current_employee(request, db, required=False)
employees = [employee] if employee and employee.tg_chat_id else []
return [_employee_out(employee) for employee in employees]
@app.post("/api/v1/employees", status_code=201)
def employee_create(payload: EmployeeCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
def employee_create(
payload: EmployeeCreate,
request: Request,
db: Session = Depends(get_db),
) -> dict[str, Any]:
_require_admin(request)
employee = Employee(
name=payload.name.strip(),
portal_user_id=_clean(payload.portal_user_id),
tg_username=_clean(payload.tg_username).lstrip("@") if _clean(payload.tg_username) else None,
tg_chat_id=_clean(payload.tg_chat_id),
)
@@ -286,10 +367,12 @@ def employee_delete(employee_id: int, request: Request, db: Session = Depends(ge
@app.get("/api/v1/projects")
def projects_list(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
def projects_list(request: Request, db: Session = Depends(get_db)) -> list[dict[str, Any]]:
employee = _current_employee(request, db)
projects = (
db.query(Project)
.options(joinedload(Project.owner), joinedload(Project.listings))
.filter(Project.owner_id == employee.id)
.order_by(Project.created_at.desc())
.all()
)
@@ -297,10 +380,8 @@ def projects_list(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
@app.post("/api/v1/projects", status_code=201)
def project_create(payload: ProjectCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
owner = db.get(Employee, payload.owner_id)
if not owner:
raise HTTPException(404, "employee not found")
def project_create(payload: ProjectCreate, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
owner = _current_employee(request, db)
project = Project(
title=payload.title.strip(),
deal_type=payload.deal_type,
@@ -320,67 +401,56 @@ def project_create(payload: ProjectCreate, db: Session = Depends(get_db)) -> dic
@app.get("/api/v1/projects/{project_id}")
def project_detail(project_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
project = (
db.query(Project)
.options(
joinedload(Project.owner),
joinedload(Project.listings).joinedload(CompetitorListing.price_history),
)
.filter(Project.id == project_id)
.first()
)
if not project:
raise HTTPException(404, "project not found")
def project_detail(project_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
project = _owned_project(request, db, project_id, with_detail=True)
return _project_out(project, detail=True)
@app.patch("/api/v1/projects/{project_id}")
def project_update(project_id: int, payload: ProjectUpdate, db: Session = Depends(get_db)) -> dict[str, Any]:
project = db.get(Project, project_id)
if not project:
raise HTTPException(404, "project not found")
def project_update(
project_id: int,
payload: ProjectUpdate,
request: Request,
db: Session = Depends(get_db),
) -> dict[str, Any]:
project = _owned_project(request, db, project_id)
data = payload.model_dump(exclude_unset=True)
if "owner_id" in data and data["owner_id"] is not None:
owner = db.get(Employee, data["owner_id"])
if not owner:
raise HTTPException(404, "employee not found")
project.owner_id = owner.id
data.pop("owner_id", None)
for field in ("title", "deal_type", "our_price", "notes", "dld_permit", "building", "bedrooms", "size_sqft", "our_url"):
if field not in data or field == "owner_id":
if field not in data:
continue
value = data[field]
if isinstance(value, str):
value = _clean(value)
setattr(project, field, value)
db.commit()
return project_detail(project_id, db)
db.refresh(project)
return _project_out(_owned_project(request, db, project_id, with_detail=True), detail=True)
@app.delete("/api/v1/projects/{project_id}", status_code=204)
def project_delete(project_id: int, request: Request, db: Session = Depends(get_db)) -> None:
_require_admin(request)
project = db.get(Project, project_id)
if not project:
raise HTTPException(404, "project not found")
project = _owned_project(request, db, project_id)
db.delete(project)
db.commit()
@app.post("/api/v1/projects/{project_id}/check")
def project_check_now(project_id: int, db: Session = Depends(get_db)) -> dict[str, int]:
if not db.get(Project, project_id):
raise HTTPException(404, "project not found")
def project_check_now(project_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, int]:
_owned_project(request, db, project_id)
db.close()
changes = run_check_for_project(project_id)
return {"changes": changes}
@app.post("/api/v1/projects/{project_id}/listings", status_code=201)
def listing_create(project_id: int, payload: ListingCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
project = db.get(Project, project_id)
if not project:
raise HTTPException(404, "project not found")
def listing_create(
project_id: int,
payload: ListingCreate,
request: Request,
db: Session = Depends(get_db),
) -> dict[str, Any]:
project = _owned_project(request, db, project_id)
listing, err = add_competitor_url(db, project, payload.url)
if err:
raise HTTPException(400, err)
@@ -388,17 +458,25 @@ def listing_create(project_id: int, payload: ListingCreate, db: Session = Depend
@app.post("/api/v1/projects/{project_id}/listings/bulk")
def listings_bulk(project_id: int, payload: ListingsBulkCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
project = db.get(Project, project_id)
if not project:
raise HTTPException(404, "project not found")
def listings_bulk(
project_id: int,
payload: ListingsBulkCreate,
request: Request,
db: Session = Depends(get_db),
) -> dict[str, Any]:
project = _owned_project(request, db, project_id)
return add_competitor_urls(db, project, payload.urls)
@app.delete("/api/v1/listings/{listing_id}", status_code=204)
def listing_delete(listing_id: int, request: Request, db: Session = Depends(get_db)) -> None:
_require_admin(request)
listing = db.get(CompetitorListing, listing_id)
employee = _current_employee(request, db)
listing = (
db.query(CompetitorListing)
.join(Project)
.filter(CompetitorListing.id == listing_id, Project.owner_id == employee.id)
.first()
)
if not listing:
raise HTTPException(404, "listing not found")
db.delete(listing)
@@ -406,15 +484,8 @@ def listing_delete(listing_id: int, request: Request, db: Session = Depends(get_
@app.get("/api/v1/projects/{project_id}/suggest")
def project_suggest(project_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
project = (
db.query(Project)
.options(joinedload(Project.listings))
.filter(Project.id == project_id)
.first()
)
if not project:
raise HTTPException(404, "project not found")
def project_suggest(project_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
project = _owned_project(request, db, project_id)
permit = resolve_our_permit(project)
suggestions = suggest_similar(project, our_permit=permit)
return {