From fbe73fdff7e44056c95c82b1e57566b4ecc4ae4b Mon Sep 17 00:00:00 2001 From: Nikita Aksenov Date: Tue, 12 May 2026 00:20:32 +0300 Subject: [PATCH 1/7] bugfix: cors fix for dev domains --- src/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main.py b/src/main.py index d84163b..6585e6d 100644 --- a/src/main.py +++ b/src/main.py @@ -26,6 +26,8 @@ "https://labeler.parktrack.live", "https://admin.parktrack.live", "https://parktrack.live", + "https://dev.parktrack.live", + "https://admin.dev.parktrack.live", "http://localhost:5173", "http://127.0.0.1:5173" ], From f30371071f453495fa0bcdfa1f20cbcaf233932a Mon Sep 17 00:00:00 2001 From: Lukramancer <57191063+Lukramancer@users.noreply.github.com> Date: Tue, 12 May 2026 02:09:08 +0300 Subject: [PATCH 2/7] feat: add ssh deployment job --- .github/workflows/build-and-push.yml | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index 330d55d..c915fbd 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -9,6 +9,7 @@ on: env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} + IS_DEFAULT: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} jobs: build-and-push: @@ -86,3 +87,31 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max platforms: linux/amd64,linux/arm64 + + deploy: + needs: + - build-and-push + + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + if: github.event_name == 'push' + + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SERVER_IP }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + if [ "${{ env.IS_DEFAULT }}" = "true" ]; then + cd ${{ secrets.COMPOSE_DIRECTORY_PATH }} + docker compose up -d + else + cd ${{ secrets.DEVELOPMENT_COMPOSE_DIRECTORY_PATH }} + docker compose -p parktrack-dev up -d + fi \ No newline at end of file From a79eaa53320d06211ba68525f7e6bd8232508cc2 Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Tue, 12 May 2026 22:11:48 +0300 Subject: [PATCH 3/7] feat(auth): add password reset flow --- ...0012_create_password_reset_tokens.down.sql | 1 + ...000012_create_password_reset_tokens.up.sql | 11 +++ ...000012_create_password_reset_tokens.up.sql | 11 +++ src/db_models.py | 20 ++++- src/routers/auth.py | 80 ++++++++++++++++++- src/schemas/auth.py | 19 +++++ 6 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 migrations/000012_create_password_reset_tokens.down.sql create mode 100644 migrations/000012_create_password_reset_tokens.up.sql create mode 100644 migrations/up/000012_create_password_reset_tokens.up.sql diff --git a/migrations/000012_create_password_reset_tokens.down.sql b/migrations/000012_create_password_reset_tokens.down.sql new file mode 100644 index 0000000..68371a2 --- /dev/null +++ b/migrations/000012_create_password_reset_tokens.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS password_reset_tokens; diff --git a/migrations/000012_create_password_reset_tokens.up.sql b/migrations/000012_create_password_reset_tokens.up.sql new file mode 100644 index 0000000..7263651 --- /dev/null +++ b/migrations/000012_create_password_reset_tokens.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + token_id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + token_hash VARCHAR(128) NOT NULL UNIQUE, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + used_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens(user_id); +CREATE INDEX idx_password_reset_tokens_expires_at ON password_reset_tokens(expires_at); diff --git a/migrations/up/000012_create_password_reset_tokens.up.sql b/migrations/up/000012_create_password_reset_tokens.up.sql new file mode 100644 index 0000000..7263651 --- /dev/null +++ b/migrations/up/000012_create_password_reset_tokens.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + token_id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + token_hash VARCHAR(128) NOT NULL UNIQUE, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + used_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens(user_id); +CREATE INDEX idx_password_reset_tokens_expires_at ON password_reset_tokens(expires_at); diff --git a/src/db_models.py b/src/db_models.py index 4175af7..bd93372 100644 --- a/src/db_models.py +++ b/src/db_models.py @@ -85,6 +85,7 @@ class User(Base): ) sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan") memberships = relationship("PartnerMembership", back_populates="user", cascade="all, delete-orphan") + password_reset_tokens = relationship("PasswordResetToken", back_populates="user", cascade="all, delete-orphan") def __repr__(self) -> str: return f"" @@ -109,6 +110,23 @@ class Session(Base): user = relationship("User", back_populates="sessions") +# --------------------------------------------------------------------------- +# Password Reset Tokens +# --------------------------------------------------------------------------- + +class PasswordResetToken(Base): + __tablename__ = "password_reset_tokens" + + token_id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False) + token_hash = Column(String(128), unique=True, nullable=False) + expires_at = Column(DateTime(timezone=True), nullable=False) + used_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), default=_now) + + user = relationship("User", back_populates="password_reset_tokens") + + # --------------------------------------------------------------------------- # User Permissions # --------------------------------------------------------------------------- @@ -385,4 +403,4 @@ class Route(Base): selected_zone = relationship("ParkingZone", foreign_keys=[selected_zone_id]) def __repr__(self) -> str: - return f"" \ No newline at end of file + return f"" diff --git a/src/routers/auth.py b/src/routers/auth.py index dce1b7d..e6f3490 100644 --- a/src/routers/auth.py +++ b/src/routers/auth.py @@ -1,12 +1,16 @@ from __future__ import annotations +import hashlib +import os +import secrets +from datetime import datetime, timedelta, timezone from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from ..database import get_db -from ..db_models import GlobalRole, User +from ..db_models import GlobalRole, PasswordResetToken, User from ..dependencies import ( JWT_EXPIRE_SECONDS, CurrentUser, @@ -22,12 +26,19 @@ LoginRequest, MeResponse, PartnerMembershipInfo, + PasswordResetConfirmRequest, + PasswordResetConfirmResponse, + PasswordResetRequest, + PasswordResetRequestResponse, RegisterRequest, TokenResponse, ) router = APIRouter(prefix="/auth", tags=["Auth"]) +PASSWORD_RESET_TTL_MINUTES = int(os.environ.get("PASSWORD_RESET_TTL_MINUTES", "30")) +PASSWORD_RESET_RETURN_TOKEN = os.environ.get("PASSWORD_RESET_RETURN_TOKEN", "1").lower() in {"1", "true", "yes", "on"} + # --------------------------------------------------------------------------- # Вспомогательная функция сборки ответа @@ -62,6 +73,16 @@ def _build_token_response(user: User, db: Session) -> TokenResponse: ), ) + +def _hash_reset_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def _is_expired(dt: datetime) -> bool: + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt <= datetime.now(timezone.utc) + # --------------------------------------------------------------------------- # POST /auth/register # --------------------------------------------------------------------------- @@ -113,6 +134,63 @@ def login(body: LoginRequest, db: Annotated[Session, Depends(get_db)]): return _build_token_response(user, db) +# --------------------------------------------------------------------------- +# POST /auth/password-reset/request +# --------------------------------------------------------------------------- + +@router.post("/password-reset/request", status_code=status.HTTP_200_OK, response_model=PasswordResetRequestResponse) +def request_password_reset(body: PasswordResetRequest, db: Annotated[Session, Depends(get_db)]): + user = db.query(User).filter(User.email == body.email).one_or_none() + raw_token: str | None = None + + if user is not None and user.is_active: + raw_token = secrets.token_urlsafe(32) + token = PasswordResetToken( + user_id=user.user_id, + token_hash=_hash_reset_token(raw_token), + expires_at=datetime.now(timezone.utc) + timedelta(minutes=PASSWORD_RESET_TTL_MINUTES), + ) + db.add(token) + db.commit() + + return PasswordResetRequestResponse( + ok=True, + reset_token=raw_token if PASSWORD_RESET_RETURN_TOKEN else None, + ) + + +# --------------------------------------------------------------------------- +# POST /auth/password-reset/confirm +# --------------------------------------------------------------------------- + +@router.post("/password-reset/confirm", status_code=status.HTTP_200_OK, response_model=PasswordResetConfirmResponse) +def confirm_password_reset(body: PasswordResetConfirmRequest, db: Annotated[Session, Depends(get_db)]): + token = ( + db.query(PasswordResetToken) + .filter(PasswordResetToken.token_hash == _hash_reset_token(body.token)) + .one_or_none() + ) + + if token is None or token.used_at is not None or _is_expired(token.expires_at): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error_description": "Reset token is invalid or expired"}, + ) + + user = db.query(User).filter(User.user_id == token.user_id).one_or_none() + if user is None or not user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error_description": "Reset token is invalid or expired"}, + ) + + user.hashed_password = hash_password(body.new_password) + token.used_at = datetime.now(timezone.utc) + db.commit() + + return PasswordResetConfirmResponse(ok=True) + + # --------------------------------------------------------------------------- # POST /auth/logout # --------------------------------------------------------------------------- diff --git a/src/schemas/auth.py b/src/schemas/auth.py index 5deaf5a..24cf4d6 100644 --- a/src/schemas/auth.py +++ b/src/schemas/auth.py @@ -19,10 +19,29 @@ class LoginRequest(BaseModel): password: str +class PasswordResetRequest(BaseModel): + email: EmailStr + + +class PasswordResetConfirmRequest(BaseModel): + token: str = Field(min_length=16) + new_password: str = Field(min_length=6, max_length=72) + + # --------------------------------------------------------------------------- # Ответы # --------------------------------------------------------------------------- +class PasswordResetRequestResponse(BaseModel): + ok: bool = True + reset_token: str | None = None + + +class PasswordResetConfirmResponse(BaseModel): + ok: bool = True + + + class AuthUserInfo(BaseModel): user_id: int email: str From e7933edccf60d11a29640ba766b530510b3a57c7 Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Tue, 12 May 2026 22:19:20 +0300 Subject: [PATCH 4/7] feat(auth): send password reset emails --- example.env | 18 +++++++++++- src/routers/auth.py | 69 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/example.env b/example.env index 4a1c0f6..52d682b 100644 --- a/example.env +++ b/example.env @@ -10,4 +10,20 @@ POSTGRES_TEST_DB=cars_test_db # Note: port and host are hardcoded due in-compose routing DATABASE_HOST_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432" -DATABASE_URL="${DATABASE_HOST_URL}/${POSTGRES_DB}?sslmode=disable" \ No newline at end of file +DATABASE_URL="${DATABASE_HOST_URL}/${POSTGRES_DB}?sslmode=disable" + +# Password reset +# If SMTP_HOST is set, reset tokens are sent by email. +# Without SMTP, the API returns reset_token by default for local testing. +PASSWORD_RESET_LOGIN_URL="http://localhost:5173/#/login" +PASSWORD_RESET_TTL_MINUTES=30 +# PASSWORD_RESET_RETURN_TOKEN=0 + +# SMTP_HOST=smtp.example.com +# SMTP_PORT=587 +# SMTP_USERNAME=parktrack@example.com +# SMTP_PASSWORD=secret +# SMTP_FROM_EMAIL=parktrack@example.com +# SMTP_FROM_NAME=ParkTrack +# SMTP_USE_TLS=1 +# SMTP_USE_SSL=0 diff --git a/src/routers/auth.py b/src/routers/auth.py index e6f3490..cb3d9b4 100644 --- a/src/routers/auth.py +++ b/src/routers/auth.py @@ -3,8 +3,12 @@ import hashlib import os import secrets +import smtplib from datetime import datetime, timedelta, timezone +from email.message import EmailMessage +from email.utils import formataddr from typing import Annotated +from urllib.parse import urlencode from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session @@ -37,7 +41,16 @@ router = APIRouter(prefix="/auth", tags=["Auth"]) PASSWORD_RESET_TTL_MINUTES = int(os.environ.get("PASSWORD_RESET_TTL_MINUTES", "30")) -PASSWORD_RESET_RETURN_TOKEN = os.environ.get("PASSWORD_RESET_RETURN_TOKEN", "1").lower() in {"1", "true", "yes", "on"} +PASSWORD_RESET_LOGIN_URL = os.environ.get("PASSWORD_RESET_LOGIN_URL", "http://localhost:5173/#/login") +SMTP_HOST = os.environ.get("SMTP_HOST") +SMTP_PORT = int(os.environ.get("SMTP_PORT", "587")) +SMTP_USERNAME = os.environ.get("SMTP_USERNAME") +SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD") +SMTP_FROM_EMAIL = os.environ.get("SMTP_FROM_EMAIL") or SMTP_USERNAME +SMTP_FROM_NAME = os.environ.get("SMTP_FROM_NAME", "ParkTrack") +SMTP_USE_TLS = os.environ.get("SMTP_USE_TLS", "1").lower() in {"1", "true", "yes", "on"} +SMTP_USE_SSL = os.environ.get("SMTP_USE_SSL", "0").lower() in {"1", "true", "yes", "on"} +_PASSWORD_RESET_RETURN_TOKEN = os.environ.get("PASSWORD_RESET_RETURN_TOKEN") # --------------------------------------------------------------------------- @@ -83,6 +96,57 @@ def _is_expired(dt: datetime) -> bool: dt = dt.replace(tzinfo=timezone.utc) return dt <= datetime.now(timezone.utc) + +def _smtp_enabled() -> bool: + return bool(SMTP_HOST and SMTP_FROM_EMAIL) + + +def _should_return_reset_token() -> bool: + if _PASSWORD_RESET_RETURN_TOKEN is None: + return not _smtp_enabled() + return _PASSWORD_RESET_RETURN_TOKEN.lower() in {"1", "true", "yes", "on"} + + +def _build_password_reset_link(token: str, email: str) -> str: + separator = "&" if "?" in PASSWORD_RESET_LOGIN_URL else "?" + return f"{PASSWORD_RESET_LOGIN_URL}{separator}{urlencode({'reset_token': token, 'email': email})}" + + +def _send_password_reset_email(email: str, token: str) -> bool: + if not _smtp_enabled(): + return False + + reset_link = _build_password_reset_link(token, email) + message = EmailMessage() + message["Subject"] = "Сброс пароля ParkTrack" + message["From"] = formataddr((SMTP_FROM_NAME, SMTP_FROM_EMAIL)) if SMTP_FROM_NAME else SMTP_FROM_EMAIL + message["To"] = email + message.set_content( + "Вы запросили сброс пароля ParkTrack.\n\n" + f"Ссылка для сброса: {reset_link}\n\n" + f"Reset-token: {token}\n\n" + f"Токен действует {PASSWORD_RESET_TTL_MINUTES} минут. " + "Если вы не запрашивали сброс пароля, просто проигнорируйте это письмо.\n" + ) + + try: + if SMTP_USE_SSL: + with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=15) as smtp: + if SMTP_USERNAME: + smtp.login(SMTP_USERNAME, SMTP_PASSWORD or "") + smtp.send_message(message) + else: + with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=15) as smtp: + if SMTP_USE_TLS: + smtp.starttls() + if SMTP_USERNAME: + smtp.login(SMTP_USERNAME, SMTP_PASSWORD or "") + smtp.send_message(message) + return True + except Exception as exc: + print(f"Password reset email failed: {exc}") + return False + # --------------------------------------------------------------------------- # POST /auth/register # --------------------------------------------------------------------------- @@ -152,10 +216,11 @@ def request_password_reset(body: PasswordResetRequest, db: Annotated[Session, De ) db.add(token) db.commit() + _send_password_reset_email(user.email, raw_token) return PasswordResetRequestResponse( ok=True, - reset_token=raw_token if PASSWORD_RESET_RETURN_TOKEN else None, + reset_token=raw_token if raw_token and _should_return_reset_token() else None, ) From ea1bbe67bc6d4b14052705fb48180055a319999e Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Tue, 12 May 2026 22:45:14 +0300 Subject: [PATCH 5/7] fix: validate phone numbers in schemas --- src/schemas/auth.py | 9 ++++++++- src/schemas/partners.py | 14 +++++++++++++- src/schemas/users.py | 21 +++++++++++++++++++-- src/schemas/validators.py | 21 +++++++++++++++++++++ 4 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 src/schemas/validators.py diff --git a/src/schemas/auth.py b/src/schemas/auth.py index 24cf4d6..0f31263 100644 --- a/src/schemas/auth.py +++ b/src/schemas/auth.py @@ -1,6 +1,8 @@ from __future__ import annotations -from pydantic import BaseModel, EmailStr, Field +from pydantic import BaseModel, EmailStr, Field, field_validator + +from .validators import validate_optional_phone # --------------------------------------------------------------------------- @@ -13,6 +15,11 @@ class RegisterRequest(BaseModel): full_name: str | None = Field(None, max_length=255) phone: str | None = Field(None, max_length=50) + @field_validator("phone") + @classmethod + def validate_phone(cls, value: str | None) -> str | None: + return validate_optional_phone(value) + class LoginRequest(BaseModel): login: str diff --git a/src/schemas/partners.py b/src/schemas/partners.py index e9c6fd7..15f57ff 100644 --- a/src/schemas/partners.py +++ b/src/schemas/partners.py @@ -2,7 +2,9 @@ from datetime import datetime -from pydantic import BaseModel, EmailStr, Field +from pydantic import BaseModel, EmailStr, Field, field_validator + +from .validators import validate_optional_phone # --------------------------------------------------------------------------- @@ -26,6 +28,11 @@ class CreatePartnerRequest(BaseModel): contact_email: EmailStr contact_phone: str = Field(min_length=5, max_length=255) + @field_validator("contact_phone") + @classmethod + def validate_phone(cls, value: str | None) -> str | None: + return validate_optional_phone(value) + class UpdatePartnerRequest(BaseModel): legal_name: str | None = Field(None, min_length=2, max_length=255) @@ -33,6 +40,11 @@ class UpdatePartnerRequest(BaseModel): contact_phone: str | None = Field(None, min_length=5, max_length=255) is_active: bool | None = None + @field_validator("contact_phone") + @classmethod + def validate_phone(cls, value: str | None) -> str | None: + return validate_optional_phone(value) + class PartnerListResponse(BaseModel): items: list[PartnerResponse] diff --git a/src/schemas/users.py b/src/schemas/users.py index f1986da..368d32b 100644 --- a/src/schemas/users.py +++ b/src/schemas/users.py @@ -2,7 +2,9 @@ from datetime import datetime -from pydantic import BaseModel, EmailStr, Field +from pydantic import BaseModel, EmailStr, Field, field_validator + +from .validators import validate_optional_phone class UserResponse(BaseModel): @@ -22,6 +24,11 @@ class UpdateUserRequest(BaseModel): phone: str | None = Field(None, max_length=50) email: EmailStr | None = None + @field_validator("phone") + @classmethod + def validate_phone(cls, value: str | None) -> str | None: + return validate_optional_phone(value) + class UpdatePasswordRequest(BaseModel): old_password: str @@ -36,6 +43,11 @@ class AdminUpdateUserRequest(BaseModel): global_role: str | None = None is_active: bool | None = None + @field_validator("phone") + @classmethod + def validate_phone(cls, value: str | None) -> str | None: + return validate_optional_phone(value) + class UserListResponse(BaseModel): items: list[UserResponse] @@ -62,4 +74,9 @@ class AdminCreateUserRequest(BaseModel): full_name: str | None = Field(None, max_length=255) phone: str | None = Field(None, max_length=50) global_role: str = "user" - \ No newline at end of file + + @field_validator("phone") + @classmethod + def validate_phone(cls, value: str | None) -> str | None: + return validate_optional_phone(value) + diff --git a/src/schemas/validators.py b/src/schemas/validators.py new file mode 100644 index 0000000..37ae0e2 --- /dev/null +++ b/src/schemas/validators.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import re + +PHONE_VALIDATION_MESSAGE = "Номер телефона должен содержать 10-15 цифр и может начинаться с +" +_PHONE_ALLOWED_RE = re.compile(r"^\+?[0-9\s().-]+$") + + +def validate_optional_phone(value: str | None) -> str | None: + if value is None: + return None + + phone = value.strip() + if not phone: + return None + + digits = re.sub(r"\D", "", phone) + if not _PHONE_ALLOWED_RE.fullmatch(phone) or not 10 <= len(digits) <= 15: + raise ValueError(PHONE_VALIDATION_MESSAGE) + + return phone From 5f59f7b62d5426878bd52d5634d0259a841d7960 Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Fri, 15 May 2026 21:25:02 +0300 Subject: [PATCH 6/7] chore: fixed .env vars --- example.env | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/example.env b/example.env index 52d682b..914ff6e 100644 --- a/example.env +++ b/example.env @@ -17,13 +17,13 @@ DATABASE_URL="${DATABASE_HOST_URL}/${POSTGRES_DB}?sslmode=disable" # Without SMTP, the API returns reset_token by default for local testing. PASSWORD_RESET_LOGIN_URL="http://localhost:5173/#/login" PASSWORD_RESET_TTL_MINUTES=30 -# PASSWORD_RESET_RETURN_TOKEN=0 +PASSWORD_RESET_RETURN_TOKEN=0 -# SMTP_HOST=smtp.example.com -# SMTP_PORT=587 -# SMTP_USERNAME=parktrack@example.com -# SMTP_PASSWORD=secret -# SMTP_FROM_EMAIL=parktrack@example.com -# SMTP_FROM_NAME=ParkTrack -# SMTP_USE_TLS=1 -# SMTP_USE_SSL=0 +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USERNAME=parktrack@example.com +SMTP_PASSWORD=secret +SMTP_FROM_EMAIL=parktrack@example.com +SMTP_FROM_NAME=ParkTrack +SMTP_USE_TLS=1 +SMTP_USE_SSL=0 From 56b70ee84441d1c8351156438639841121244e8c Mon Sep 17 00:00:00 2001 From: Mr_GoldSky_ Date: Sat, 16 May 2026 15:38:16 +0300 Subject: [PATCH 7/7] ABOBA --- src/routers/forecasts.py | 2 +- src/routers/occupancy.py | 2 +- src/routers/routing.py | 35 ++++++++++++++++++++++++++++++----- src/routers/zones.py | 2 +- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/routers/forecasts.py b/src/routers/forecasts.py index b59e030..5abe962 100644 --- a/src/routers/forecasts.py +++ b/src/routers/forecasts.py @@ -92,7 +92,7 @@ def _parse_bbox(bbox: str) -> tuple[float, float, float, float]: | list[ForecastMapItem], ) def list_forecasts( - current_user: Annotated[User, require("forecasts.view")], + # 2026-05-16: открыто без авторизации (по запросу) — режим «Будущее» на карте. db: Annotated[Session, Depends(get_db)], zone_id: int | None = None, camera_id: int | None = None, diff --git a/src/routers/occupancy.py b/src/routers/occupancy.py index 5150a1d..95c5509 100644 --- a/src/routers/occupancy.py +++ b/src/routers/occupancy.py @@ -94,7 +94,7 @@ def _parse_bbox(bbox: str) -> tuple[float, float, float, float]: | list[OccupancyMapItem], ) def list_occupancy( - current_user: Annotated[User, require("occupancy.view")], + # 2026-05-16: открыто без авторизации (по запросу) — режим «Прошлое» на карте. db: Annotated[Session, Depends(get_db)], zone_id: int | None = None, camera_id: int | None = None, diff --git a/src/routers/routing.py b/src/routers/routing.py index 1b53374..2461743 100644 --- a/src/routers/routing.py +++ b/src/routers/routing.py @@ -78,6 +78,29 @@ def _assert_owner_or_admin(route: Route, current_user: User) -> None: ) +_ANON_EMAIL = "anonymous@parktrack.local" + + +def _anon_user(db: Session) -> User: + """2026-05-16: backend открыт без авторизации (по запросу). Route.user_id — + NOT NULL FK на users, null нельзя без миграции. Анонимные маршруты вешаем + на системного пользователя (get-or-create по фикс. email). hashed_password + невалиден → залогиниться этим юзером нельзя.""" + u = db.query(User).filter(User.email == _ANON_EMAIL).one_or_none() + if u is None: + u = User( + email=_ANON_EMAIL, + hashed_password="!", + full_name="Anonymous", + global_role=GlobalRole.user, + is_active=True, + ) + db.add(u) + db.commit() + db.refresh(u) + return u + + def _build_candidate_from_zone( zone: ParkingZone, origin: GeoPoint, @@ -116,9 +139,10 @@ def _build_candidate_from_zone( @router.post("/search", response_model=SearchRoutingResponse) def search_routing( body: SearchRoutingRequest, - current_user: Annotated[User, require("routing.create")], db: Annotated[Session, Depends(get_db)], ): + # 2026-05-16: открыто без авторизации (по запросу). /search ничего не + # сохраняет — current_user не нужен. candidates = find_candidates( db=db, origin=body.origin, @@ -153,9 +177,10 @@ def search_routing( @router.post("/new", status_code=status.HTTP_201_CREATED, response_model=RouteResponse) def create_route( body: CreateRouteRequest, - current_user: Annotated[User, require("routing.create")], db: Annotated[Session, Depends(get_db)], ): + # 2026-05-16: открыто без авторизации (по запросу). Владелец — системный + # anon-пользователь (Route.user_id NOT NULL, см. _anon_user). candidates = find_candidates( db=db, origin=body.origin, @@ -198,7 +223,7 @@ def create_route( deeplink = build_deeplink(body.provider, z_lat, z_lon) route = Route( - user_id=current_user.user_id, + user_id=_anon_user(db).user_id, mode=RouteMode(body.mode), provider=body.provider, origin_latitude=body.origin.latitude, @@ -275,11 +300,11 @@ def list_routes( @router.get("/{route_id}", response_model=RouteResponse) def get_route( route_id: int, - current_user: Annotated[User, require("routing.view")], db: Annotated[Session, Depends(get_db)], ): + # 2026-05-16: открыто без авторизации (по запросу). Маршрут читается по id + # без проверки владельца (RoutePreviewLayer тянет ?route=N после reload). route = _get_route_or_404(db, route_id) - _assert_owner_or_admin(route, current_user) return _serialize_route(route) diff --git a/src/routers/zones.py b/src/routers/zones.py index 5fc17f1..30c126f 100644 --- a/src/routers/zones.py +++ b/src/routers/zones.py @@ -192,9 +192,9 @@ def create_zone( @router.get("/{zone_id}", response_model=ZoneResponse) def get_zone( zone_id: int, - current_user: Annotated[User, require("zones.view")], db: Annotated[Session, Depends(get_db)], ): + # 2026-05-16: открыто без авторизации (по запросу) — как и GET /zones?view=map. return _serialize(_get_zone_or_404(db, zone_id), db)