Initial commit – TimeMaster Zeiterfassung & HR-Tool

Stand: agent-06 (Audit-Log), agent-05 (Krankmeldung), agent-07 Phase 1 (Personalnummer),
Busylight-Pull-Integration, TOTP/2FA, Abwesenheiten, Zeiterfassung, Kiosk-Grundgerüst.
Migrations 0001–0023 deployed auf 192.168.1.137 + .164.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-05-23 20:03:27 +02:00
commit 1fedd683e0
178 changed files with 29896 additions and 0 deletions
+206
View File
@@ -0,0 +1,206 @@
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()
await db.refresh(project)
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()
await db.refresh(project)
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,
)