Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add work summary panel #786

Merged
merged 5 commits into from
Feb 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion api/helpers/time.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from datetime import timedelta
from typing import List
from datetime import timedelta, date
import math
from models.user import UserCapacity


def time_string_to_int(time_string: str) -> int:
Expand All @@ -21,3 +24,55 @@ def total_hours_between_dates(start_date, end_date, hours_per_weekday):
current_date += timedelta(days=1)

return total_hours


def int_to_time_long_string(time_minutes: int) -> str:
return int_to_time_string(time_minutes).replace(":", "h ") + "m"


def vacation_int_to_string(time_minutes: int, user_capacity: float) -> str:
if time_minutes == 0:
return "None"

hours_total = math.floor(time_minutes / 60)
remaining_total_minutes = time_minutes % 60
days = math.floor(time_minutes / (user_capacity * 60))
leftover_minutes = time_minutes % (user_capacity * 60)
hours = math.floor(leftover_minutes / 60)
mins = time_minutes - (days * (user_capacity * 60)) - (hours * 60)

vacation_time_string = ""
if days > 0:
vacation_time_string += f"{days} days"
if hours > 0:
vacation_time_string += " " if days > 0 else ""
vacation_time_string += f"{hours} h"
if mins > 0:
vacation_time_string += " " if hours > 0 or days > 0 else ""
vacation_time_string += f"{mins:.0f} m"
if hours_total > 0 or remaining_total_minutes > 0:
hours_total_string = f"{hours_total} h" if hours_total > 0 else ""
remaining_mins_string = f" {remaining_total_minutes} m" if remaining_total_minutes > 0 else ""
vacation_time_string += f" ({hours_total_string}{remaining_mins_string})"

return f"{vacation_time_string}"


def get_start_and_end_date_of_isoweek(current_date: date) -> []:
weekday = current_date.isoweekday()
start = current_date - timedelta(days=weekday)
end = start + timedelta(days=6)
return [start, end]


def get_expected_worked_hours(user_capacities: List[UserCapacity], start_date: date, end_date: date) -> float:
end_date = end_date + timedelta(days=1)
days_in_range = (start_date + timedelta(x) for x in range((end_date - start_date).days))
expected = 0
for day in days_in_range:
if day.weekday() > 4:
continue
capacity = [x.capacity for x in user_capacities if day >= x.start and day <= x.end]
if capacity:
expected = expected + capacity[0]
return expected
69 changes: 68 additions & 1 deletion api/routers/v1/timelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
Task as TaskSchema,
TaskNew as TaskNewSchema,
TaskUpdate as TaskUpdateSchema,
Summary,
)
from schemas.validation import ValidatedObject
from schemas.user import AppUser
Expand All @@ -21,7 +22,13 @@
from db.db_connection import get_db
from auth.auth_bearer import BearerToken
from dependencies import get_current_user, PermissionsValidator
from helpers.time import time_string_to_int
from helpers.time import (
time_string_to_int,
int_to_time_long_string,
get_start_and_end_date_of_isoweek,
vacation_int_to_string,
get_expected_worked_hours,
)

router = APIRouter(
prefix="/timelog",
Expand Down Expand Up @@ -273,6 +280,66 @@ async def delete_task(task_id: int, current_user=Depends(get_current_user), db:
return


@router.get("/summary", response_model=Summary)
async def get_user_work_summary(
ref_date: date = date.today(), current_user=Depends(get_current_user), db: Session = Depends(get_db)
):
[week_start, week_end] = get_start_and_end_date_of_isoweek(ref_date)
if ref_date != date.today():
current_capacity = [cap for cap in current_user.capacities if cap.start <= ref_date and cap.end >= ref_date]
else:
current_capacity = [cap for cap in current_user.capacities if cap.is_current]
summary = Summary(
today=TaskService(db).get_tasks_sum(current_user.id, ref_date, ref_date),
week=TaskService(db).get_tasks_sum(current_user.id, week_start, week_end),
project_summaries=TaskService(db).get_task_totals_projects(current_user.id, ref_date),
vacation_available=0,
expected_hours_year=0,
expected_hours_week=get_expected_worked_hours(current_user.capacities, week_start, week_end),
worked_hours_year=TaskService(db).get_tasks_sum(current_user.id, ref_date.replace(month=1, day=1), ref_date)
/ 60,
vacation_used=TaskService(db).get_vacation_used(current_user.id, ref_date),
vacation_scheduled=TaskService(db).get_vacation_scheduled(current_user.id, ref_date),
)

for c in current_user.capacities:
for a in c.yearly_expected_and_vacation:
if str(ref_date.year) in a:
this_year = str(ref_date.year)
summary.vacation_available += a[this_year]["availableVacation"]
summary.expected_hours_year += a[this_year]["expectedHours"]

# round to nearest minute instead of accounting for seconds
summary.vacation_available = round(summary.vacation_available * 60)
summary.today_text = int_to_time_long_string(summary.today) if summary.today is not None else "0h 0m"
summary.week_text = int_to_time_long_string(summary.week) if summary.week is not None else "0h 0m"
summary.vacation_scheduled_text = (
vacation_int_to_string(summary.vacation_scheduled, current_capacity[0].capacity)
if summary.vacation_scheduled is not None
else "0h 0m"
)
summary.vacation_used_text = (
vacation_int_to_string(summary.vacation_used, current_capacity[0].capacity)
if summary.vacation_used is not None
else "0h 0m"
)
summary.vacation_available_text = (
vacation_int_to_string(summary.vacation_available, current_capacity[0].capacity)
if summary.vacation_available is not None
else "0h 0m"
)
summary.vacation_pending = summary.vacation_available - summary.vacation_used - summary.vacation_scheduled
summary.vacation_pending_text = (
vacation_int_to_string(summary.vacation_pending, current_capacity[0].capacity)
if summary.vacation_pending is not None
else "0h 0m"
)
year_start = ref_date.replace(month=1, day=1)
summary.expected_hours_to_date = get_expected_worked_hours(current_user.capacities, year_start, ref_date)

return summary


def validate_task(task_to_validate: TaskSchema, db: Session):
validated = ValidatedObject(is_valid=False, message="")
user_can_create_tasks = ConfigService(db).can_user_edit_task(task_to_validate.date)
Expand Down
34 changes: 33 additions & 1 deletion api/schemas/timelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
model_validator,
)
from pydantic.alias_generators import to_camel
from typing import Optional, Any
from typing import Optional, Any, List
from typing_extensions import Annotated
from helpers.time import time_string_to_int

Expand Down Expand Up @@ -152,3 +152,35 @@ class Task(TaskBase):
id: int
project_name: str
customer_name: Optional[str] = None


class ProjectTaskSummary(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
project_id: Optional[int] = None
project: Optional[str] = None
today_total: Optional[int] = None
today_text: Optional[str] = None
week_total: Optional[int] = None
week_text: Optional[str] = None
is_vacation: bool


class Summary(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
today: Optional[int] = 0
week: Optional[int] = 0
today_text: Optional[str] = None
week_text: Optional[str] = None
project_summaries: List[ProjectTaskSummary] = None
vacation_available: Optional[int] = None
vacation_available_text: Optional[str] = None
vacation_used: Optional[int] = None
vacation_used_text: Optional[str] = None
vacation_scheduled: Optional[int] = None
vacation_scheduled_text: Optional[str] = None
vacation_pending: Optional[int] = None
vacation_pending_text: Optional[str] = None
expected_hours_year: Optional[float] = None
expected_hours_to_date: Optional[float] = None
expected_hours_week: Optional[float] = None
worked_hours_year: Optional[float] = None
73 changes: 70 additions & 3 deletions api/services/timelog.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from typing import List
from datetime import date, datetime
from sqlalchemy import or_
from datetime import date, datetime, timedelta
from sqlalchemy import or_, func
from fastapi.encoders import jsonable_encoder

from services.main import AppService
from models.timelog import TaskType, Template, Task
from schemas.timelog import TemplateNew, TemplateUpdate, TaskNew, TaskUpdate
from models.config import Config
from schemas.timelog import TemplateNew, TemplateUpdate, TaskNew, TaskUpdate, ProjectTaskSummary
from schemas.validation import ValidatedObject


Expand Down Expand Up @@ -137,3 +138,69 @@ def check_task_for_overlap(self, task: Task) -> ValidatedObject:
f" {user_task_for_day.start_time} to {user_task_for_day.end_time}."
)
return validated_task

def get_tasks_sum(self, user_id: int, start: date, end: date) -> int:
task_sum = (
self.db.query(func.sum(Task.task_total_minutes).label("task_sum"))
.filter(Task.user_id == user_id, Task.date.between(start, end))
.first()[0]
)
if task_sum is None:
task_sum = 0
return task_sum

def get_task_totals_projects(self, user_id: int, current_date: date) -> List[ProjectTaskSummary]:
totals = self.db.query(
func.public.get_user_project_summaries(user_id, current_date).table_valued(
"project_id", "project", "today_total", "today_text", "week_total", "week_text", "is_vacation"
)
).all()
project_totals_list = []

for total in totals:
project_totals_list.append(
ProjectTaskSummary(
project_id=total[0],
project=total[1],
today_total=total[2] or 0,
today_text=total[3],
week_total=total[4] or 0,
week_text=total[5],
is_vacation=total[6],
)
)

return project_totals_list

def get_vacation_used(self, user_id: int, ref_date: date) -> int:
config = self.db.query(Config).first()
year_start = ref_date.replace(month=1, day=1)
used = (
self.db.query(func.sum(Task.task_total_minutes).label("vacation_used"))
.filter(
Task.user_id == user_id,
Task.project_id == config.vacation_project_id,
Task.date.between(year_start, ref_date),
)
.first()[0]
)
if used is None:
used = 0
return used

def get_vacation_scheduled(self, user_id: int, ref_date: date) -> int:
config = self.db.query(Config).first()
tomorrow = ref_date + timedelta(days=1)
yearEnd = ref_date.replace(month=12, day=31)
scheduled = (
self.db.query(func.sum(Task.task_total_minutes).label("vacation_sum"))
.filter(
Task.user_id == user_id,
Task.project_id == config.vacation_project_id,
Task.date.between(tomorrow, yearEnd),
)
.first()[0]
)
if scheduled is None:
scheduled = 0
return scheduled
32 changes: 32 additions & 0 deletions api/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,35 @@ def get_user_missing_scopes_token_headers(client: TestClient) -> Dict[str, str]:
token = create_access_token(user)
headers = {"Authorization": f"Bearer {token}"}
return headers


@pytest.fixture(scope="module")
def get_admin_user_token_headers(client: TestClient) -> Dict[str, str]:
user = {
"aud": "account",
"roles": ["Admin User"],
"name": "RuPaul",
"preferred_username": "admin",
"given_name": "Paul",
"family_name": "Ru",
"email": "rupaul@dragrace.tv",
}
token = create_access_token(user)
headers = {"Authorization": f"Bearer {token}"}
return headers


@pytest.fixture(scope="module")
def get_manager_user_token_headers(client: TestClient) -> Dict[str, str]:
user = {
"aud": "account",
"roles": ["Manager User"],
"name": "Jean-Luc Picard",
"preferred_username": "manager",
"given_name": "Jean-Luc",
"family_name": "Picard",
"email": "jlp@enterprise-d.com",
}
token = create_access_token(user)
headers = {"Authorization": f"Bearer {token}"}
return headers
42 changes: 41 additions & 1 deletion api/tests/helpers/test_time.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
from datetime import datetime

from api.helpers.time import total_hours_between_dates
from api.helpers.time import (
total_hours_between_dates,
time_string_to_int,
int_to_time_string,
int_to_time_long_string,
vacation_int_to_string,
get_start_and_end_date_of_isoweek,
)


def test_total_hours_between_dates() -> None:
Expand All @@ -10,3 +17,36 @@ def test_total_hours_between_dates() -> None:

# There are 10 weekdays in the period set above, so 10 * 8 == 80
assert total_hours_between_dates(start_date, end_date, hours_per_weekday) == 80.0


def test_time_string_to_int() -> None:
time_string = "4:30"

assert time_string_to_int(time_string) == 270


def test_into_to_time_string() -> None:
time_minutes = 400

assert int_to_time_string(time_minutes) == "6:40"


def test_int_to_time_long_string() -> None:
time_minutes = 870

assert int_to_time_long_string(time_minutes) == "14h 30m"


def test_vacation_int_to_string() -> None:
time_minutes = 1285
user_capacity = 8.0

assert vacation_int_to_string(time_minutes, user_capacity) == "2 days 5 h 25 m (21 h 25 m)"


def test_get_start_and_end_date_of_week() -> None:
current_date = datetime(2024, 1, 10)
start_and_end = get_start_and_end_date_of_isoweek(current_date)

assert start_and_end[0] == datetime(2024, 1, 7)
assert start_and_end[1] == datetime(2024, 1, 13)
Loading
Loading