dd3e069466
SET LOCAL Werte (bypass_rls, company_id) sind transaktions-gebunden. Nach db.commit() ist der Kontext weg – ein nachfolgendes db.refresh() läuft in einer neuen Transaktion ohne RLS-Kontext und liefert 0 Rows. Da expire_on_commit=False gesetzt ist, sind alle Instanz-Attribute nach dem Commit bereits im Speicher vorhanden. Die expliziten db.refresh()-Aufrufe nach db.commit() in allen Routers sind daher redundant und wurden entfernt. test_rls.py: 6 neue Tests beweisen DB-seitige Mandanten-Isolation. conftest.py: _apply_rls() wendet RLS-Policies auf Test-DB an. migrations/0024: korrigiert auf op.execute(text()) API. migrations/env.py: SET LOCAL außerhalb Transaktion entfernt. Ergebnis: 8 failed (pre-existing), 126 passed – identisch zur Baseline vor RLS. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
205 lines
6.5 KiB
Python
205 lines
6.5 KiB
Python
from datetime import date
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.database import get_db
|
|
from app.core.dependencies import CurrentUser, require_role
|
|
from app.models.project import Project
|
|
from app.models.time_entry import TimeEntry, EntryStatus
|
|
from app.models.user import User, UserRole
|
|
from app.schemas.project import (
|
|
ProjectCreate,
|
|
ProjectListResponse,
|
|
ProjectOut,
|
|
ProjectTimeReport,
|
|
ProjectUpdate,
|
|
)
|
|
|
|
router = APIRouter(prefix="/projects", tags=["Projekte"])
|
|
|
|
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
|
|
|
|
|
def _assert_company(project: Project, company_id: UUID) -> None:
|
|
if project.company_id != company_id:
|
|
raise HTTPException(404, "Projekt nicht gefunden")
|
|
|
|
|
|
@router.get("", response_model=ProjectListResponse)
|
|
async def list_projects(
|
|
current_user: CurrentUser,
|
|
db: AsyncSession = Depends(get_db),
|
|
include_inactive: bool = Query(False),
|
|
):
|
|
stmt = select(Project).where(Project.company_id == current_user.company_id)
|
|
if not include_inactive:
|
|
stmt = stmt.where(Project.is_active == True)
|
|
stmt = stmt.order_by(Project.name)
|
|
result = await db.scalars(stmt)
|
|
items = list(result.all())
|
|
return ProjectListResponse(total=len(items), items=items)
|
|
|
|
|
|
@router.post("", response_model=ProjectOut, status_code=201)
|
|
async def create_project(
|
|
data: ProjectCreate,
|
|
current_user: User = require_role(*_manager_roles),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
project = Project(
|
|
company_id=current_user.company_id,
|
|
name=data.name,
|
|
description=data.description,
|
|
color=data.color,
|
|
budget_hours=data.budget_hours,
|
|
)
|
|
db.add(project)
|
|
await db.commit()
|
|
return project
|
|
|
|
|
|
@router.get("/report/summary", response_model=list[ProjectTimeReport])
|
|
async def projects_summary(
|
|
current_user: User = require_role(*_manager_roles),
|
|
db: AsyncSession = Depends(get_db),
|
|
date_from: date | None = Query(None),
|
|
date_to: date | None = Query(None),
|
|
):
|
|
projects = list((await db.scalars(
|
|
select(Project).where(Project.company_id == current_user.company_id, Project.is_active == True)
|
|
)).all())
|
|
|
|
result = []
|
|
for project in projects:
|
|
stmt = (
|
|
select(TimeEntry)
|
|
.join(TimeEntry.user)
|
|
.where(
|
|
TimeEntry.project_id == project.id,
|
|
User.company_id == current_user.company_id,
|
|
TimeEntry.status == EntryStatus.APPROVED,
|
|
TimeEntry.end_time.is_not(None),
|
|
)
|
|
)
|
|
if date_from:
|
|
stmt = stmt.where(TimeEntry.date >= date_from)
|
|
if date_to:
|
|
stmt = stmt.where(TimeEntry.date <= date_to)
|
|
|
|
entries = list((await db.scalars(stmt)).all())
|
|
total_minutes = sum(e.worked_minutes or 0 for e in entries)
|
|
total_hours = round(total_minutes / 60, 2)
|
|
|
|
budget_used_pct = None
|
|
if project.budget_hours and float(project.budget_hours) > 0:
|
|
budget_used_pct = round(total_hours / float(project.budget_hours) * 100, 1)
|
|
|
|
result.append(ProjectTimeReport(
|
|
project_id=project.id,
|
|
project_name=project.name,
|
|
project_color=project.color,
|
|
total_hours=total_hours,
|
|
entry_count=len(entries),
|
|
budget_hours=float(project.budget_hours) if project.budget_hours else None,
|
|
budget_used_pct=budget_used_pct,
|
|
))
|
|
|
|
result.sort(key=lambda x: x.total_hours, reverse=True)
|
|
return result
|
|
|
|
|
|
@router.get("/{project_id}", response_model=ProjectOut)
|
|
async def get_project(
|
|
project_id: UUID,
|
|
current_user: CurrentUser,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
project = await db.get(Project, project_id)
|
|
if not project:
|
|
raise HTTPException(404, "Projekt nicht gefunden")
|
|
_assert_company(project, current_user.company_id)
|
|
return project
|
|
|
|
|
|
@router.patch("/{project_id}", response_model=ProjectOut)
|
|
async def update_project(
|
|
project_id: UUID,
|
|
data: ProjectUpdate,
|
|
current_user: User = require_role(*_manager_roles),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
project = await db.get(Project, project_id)
|
|
if not project:
|
|
raise HTTPException(404, "Projekt nicht gefunden")
|
|
_assert_company(project, current_user.company_id)
|
|
|
|
for field, value in data.model_dump(exclude_unset=True).items():
|
|
setattr(project, field, value)
|
|
|
|
await db.commit()
|
|
return project
|
|
|
|
|
|
@router.delete("/{project_id}", status_code=204)
|
|
async def delete_project(
|
|
project_id: UUID,
|
|
current_user: User = require_role(*_manager_roles),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
project = await db.get(Project, project_id)
|
|
if not project:
|
|
raise HTTPException(404, "Projekt nicht gefunden")
|
|
_assert_company(project, current_user.company_id)
|
|
project.is_active = False
|
|
await db.commit()
|
|
|
|
|
|
@router.get("/{project_id}/report", response_model=ProjectTimeReport)
|
|
async def project_time_report(
|
|
project_id: UUID,
|
|
current_user: CurrentUser,
|
|
db: AsyncSession = Depends(get_db),
|
|
date_from: date | None = Query(None),
|
|
date_to: date | None = Query(None),
|
|
):
|
|
project = await db.get(Project, project_id)
|
|
if not project:
|
|
raise HTTPException(404, "Projekt nicht gefunden")
|
|
_assert_company(project, current_user.company_id)
|
|
|
|
stmt = (
|
|
select(TimeEntry)
|
|
.join(TimeEntry.user)
|
|
.where(
|
|
TimeEntry.project_id == project_id,
|
|
User.company_id == current_user.company_id,
|
|
TimeEntry.status == EntryStatus.APPROVED,
|
|
TimeEntry.end_time.is_not(None),
|
|
)
|
|
)
|
|
if date_from:
|
|
stmt = stmt.where(TimeEntry.date >= date_from)
|
|
if date_to:
|
|
stmt = stmt.where(TimeEntry.date <= date_to)
|
|
|
|
entries = list((await db.scalars(stmt)).all())
|
|
total_minutes = sum(e.worked_minutes or 0 for e in entries)
|
|
total_hours = round(total_minutes / 60, 2)
|
|
|
|
budget_used_pct = None
|
|
if project.budget_hours and float(project.budget_hours) > 0:
|
|
budget_used_pct = round(total_hours / float(project.budget_hours) * 100, 1)
|
|
|
|
return ProjectTimeReport(
|
|
project_id=project.id,
|
|
project_name=project.name,
|
|
project_color=project.color,
|
|
total_hours=total_hours,
|
|
entry_count=len(entries),
|
|
budget_hours=float(project.budget_hours) if project.budget_hours else None,
|
|
budget_used_pct=budget_used_pct,
|
|
)
|