diff --git a/api/dependencies.py b/api/dependencies.py index 3e74df489..9a5d4a42b 100644 --- a/api/dependencies.py +++ b/api/dependencies.py @@ -18,21 +18,23 @@ class AppUser(BaseModel): - id: Optional[int] - username: Optional[str] - email: Optional[str] - first_name: Optional[str] - last_name: Optional[str] - roles: Optional[List[str]] + id: Optional[int] = None + username: Optional[str] = None + email: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + roles: Optional[List[str]] = None async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)], db: Session = Depends(get_db)): decoded = decode_token(token) - user_in_db = UserService(db).get_user(decoded[OIDC_USERNAME_PROPERTY]) + username = decoded[OIDC_USERNAME_PROPERTY] + user_in_db = UserService(db).get_user(username=username) if not user_in_db: raise HTTPException(status_code=401, detail="You are not an authorized user.") user = AppUser( id=user_in_db.id, + username=username, email=decoded["email"], first_name=decoded["given_name"], last_name=decoded["family_name"], diff --git a/api/helpers/time.py b/api/helpers/time.py new file mode 100644 index 000000000..97638bf19 --- /dev/null +++ b/api/helpers/time.py @@ -0,0 +1,10 @@ +from datetime import timedelta + + +def time_string_to_int(time_string: str) -> int: + hours, minutes = time_string.split(":") + return int(hours) * 60 + int(minutes) + + +def int_to_time_string(time_minutes: int) -> str: + return str(timedelta(minutes=time_minutes)) diff --git a/api/models/timelog.py b/api/models/timelog.py index cb8503e6a..194597c06 100644 --- a/api/models/timelog.py +++ b/api/models/timelog.py @@ -14,6 +14,7 @@ from sqlalchemy.ext.hybrid import hybrid_property from models.project import Project from models.user import User +from helpers.time import int_to_time_string from db.base_class import Base @@ -45,6 +46,14 @@ def project_name(self): def customer_name(self): return self.project.customer_name + @hybrid_property + def start_time(self): + return int_to_time_string(self.init) + + @hybrid_property + def end_time(self): + return int_to_time_string(self.end) + __table_args__ = (UniqueConstraint("usrid", "init", "_date", name="unique_task_usr_time"),) @@ -77,13 +86,21 @@ class Template(Base): onsite = Column(Boolean, nullable=True) description = Column("text", String(length=8192), nullable=True) task_type = Column("ttype", String(length=40), nullable=True) - init_time = Column(Integer, nullable=True) - end_time = Column(Integer, nullable=True) + init = Column("init_time", Integer, nullable=True) + end = Column("end_time", Integer, nullable=True) customer_id = Column("customerid", Integer, ForeignKey("customer.id"), nullable=True) user_id = Column("usrid", Integer, ForeignKey(User.id), nullable=True) project_id = Column("projectid", Integer, ForeignKey("project.id"), nullable=True) is_global = Column(Boolean, nullable=False, default=False) + @hybrid_property + def start_time(self): + return int_to_time_string(self.init) if self.init else None + + @hybrid_property + def end_time(self): + return int_to_time_string(self.end) if self.end else None + class TotalHoursOverride(Base): __tablename__ = "user_goals" diff --git a/api/pyproject.toml b/api/pyproject.toml index b10e2107f..30c430de0 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -15,7 +15,7 @@ readme = "README.md" dependencies = [ "PyJWT == 2.7.0", "python-decouple == 3.8", - "pydantic == 1.10.7", + "pydantic == 2.4.2", "psycopg2-binary == 2.9.6", "alembic == 1.10.4", "sqlalchemy == 2.0.12", diff --git a/api/routers/v1/timelog.py b/api/routers/v1/timelog.py index 6702496c8..91275d8fb 100644 --- a/api/routers/v1/timelog.py +++ b/api/routers/v1/timelog.py @@ -8,9 +8,12 @@ Template as TemplateSchema, TemplateNew as TemplateNewSchema, Task as TaskSchema, + TaskNew as TaskNewSchema, + TaskValidate, ) from services.timelog import TaskTypeService, TemplateService, TaskService - +from services.projects import ProjectService +from services.config import ConfigService from db.db_connection import get_db from auth.auth_bearer import BearerToken from dependencies import get_current_user @@ -24,7 +27,9 @@ @router.get("/task_types/", response_model=List[TaskTypeItem]) -async def get_task_types(db: Session = Depends(get_db), skip: int = 0, limit: int = 100): +async def get_task_types( + current_user=Depends(get_current_user), db: Session = Depends(get_db), skip: int = 0, limit: int = 100 +): items = TaskTypeService(db).get_items() return items @@ -48,7 +53,7 @@ async def add_template( - **story**: the task story - **description**: the task description - **task type**: the task type - - **init time**: the task start time + - **start time**: the task start time - **end time**: the task end time - **user id**: the user id (global templates should leave this null; user template should fill) - **project id**: the project id @@ -79,3 +84,52 @@ async def get_user_tasks( raise HTTPException(status_code=403, detail="You are not authorized to view tasks for this user") tasks = TaskService(db).get_user_tasks(user_id, offset, limit, start, end) return tasks + + +@router.post("/tasks", response_model=TaskSchema, status_code=201) +async def add_task(task: TaskNewSchema, current_user=Depends(get_current_user), db: Session = Depends(get_db)): + """ + Create a task with the following data: + + - **user id**: the user id + - **project_id**: the project the task is associated with + - **story**: the task story + - **description**: the task description + - **task type**: the task type + - **date**: the task date + - **start time**: the task start time + - **end time**: the task end time + + \f + :param item: User input. + """ + if current_user.id != task.user_id: + raise HTTPException(status_code=403, detail="You are not authorized to create tasks for this user.") + validated_task = validate_task(task, db) + if not validated_task.is_task_valid: + raise HTTPException(status_code=422, detail=validated_task.message) + result = TaskService(db).create_task(task) + return result + + +def validate_task(task: TaskSchema, db: Session): + validated = TaskValidate(is_task_valid=False, message="") + user_can_create_tasks = ConfigService(db).can_user_edit_task(task.date) + if not user_can_create_tasks: + validated.message += "You cannot create or edit a task for this date - it is outside the allowed range." + return validated + if task.task_type: + task_type_valid = TaskTypeService(db).slug_is_valid(task.task_type) + if not task_type_valid: + validated.message += f"Task type {task.task_type} does not exist." + return validated + project_active = ProjectService(db).is_project_active(task.project_id) + if not project_active: + validated.message += "You cannot add a task for an inactive project." + return validated + overlapping_tasks = TaskService(db).check_task_for_overlap(task) + if not overlapping_tasks.is_task_valid: + validated.message += overlapping_tasks.message + return validated + validated.is_task_valid = True + return validated diff --git a/api/schemas/customer.py b/api/schemas/customer.py index dc9fa371e..706a903ed 100644 --- a/api/schemas/customer.py +++ b/api/schemas/customer.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import ConfigDict, BaseModel from typing import Optional @@ -6,8 +6,6 @@ class Customer(BaseModel): id: int name: str customer_type: str - url: Optional[str] + url: Optional[str] = None sector_id: int - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) diff --git a/api/schemas/project.py b/api/schemas/project.py index 85881a452..1d356ff5e 100644 --- a/api/schemas/project.py +++ b/api/schemas/project.py @@ -1,22 +1,20 @@ from datetime import date -from pydantic import BaseModel +from pydantic import ConfigDict, BaseModel from typing import Optional class Project(BaseModel): id: str is_active: bool - init: Optional[date] - end: Optional[date] - invoice: Optional[float] - estimated_hours: Optional[float] - moved_hours: Optional[float] - description: Optional[str] - project_type: Optional[str] - schedule_type: Optional[str] + init: Optional[date] = None + end: Optional[date] = None + invoice: Optional[float] = None + estimated_hours: Optional[float] = None + moved_hours: Optional[float] = None + description: Optional[str] = None + project_type: Optional[str] = None + schedule_type: Optional[str] = None customer_id: int area_id: int - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) diff --git a/api/schemas/timelog.py b/api/schemas/timelog.py index 116134db0..6e8399c8a 100644 --- a/api/schemas/timelog.py +++ b/api/schemas/timelog.py @@ -1,52 +1,59 @@ from datetime import date -from pydantic import BaseModel, root_validator, constr -from typing import Optional +from pydantic import StringConstraints, ConfigDict, BaseModel, computed_field, model_validator +from typing import Optional, Any +from typing_extensions import Annotated +from helpers.time import time_string_to_int class TaskTypeItem(BaseModel): slug: str name: str active: bool - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) # Shared properties class TemplateBase(BaseModel): - name: Optional[constr(max_length=80)] - story: Optional[constr(max_length=80)] - description: Optional[constr(max_length=8192)] - task_type: Optional[constr(max_length=40)] - init_time: Optional[int] - end_time: Optional[int] + name: Optional[Annotated[str, StringConstraints(max_length=80)]] + story: Optional[Annotated[str, StringConstraints(max_length=80)]] + description: Optional[Annotated[str, StringConstraints(max_length=8192)]] + task_type: Optional[Annotated[str, StringConstraints(max_length=40)]] customer_id: Optional[int] user_id: Optional[int] project_id: Optional[int] is_global: Optional[bool] - - @root_validator(pre=True) - def user_template_not_global(cls, values): - user_id, is_global = values.get("user_id"), values.get("is_global") - if is_global and user_id is not None: - raise ValueError("Global templates should not have a user_id.") - if not is_global and not user_id: - raise ValueError("Private templates should have a user_id.") - return values + start_time: Optional[str] + end_time: Optional[str] # Properties to receive on creation class TemplateNew(TemplateBase): - name: constr(max_length=80) + name: Annotated[str, StringConstraints(max_length=80)] is_global: bool = False + @computed_field + @property + def init(self) -> Optional[int]: + if not self.start_time: + return None + + return time_string_to_int(self.start_time) + + @computed_field + @property + def end(self) -> Optional[int]: + if not self.end_time: + return None + + return time_string_to_int(self.end_time) + # Properties shared by models stored in db class TemplateInDb(TemplateBase): id: int - - class Config: - orm_mode = True + init: Optional[int] + end: Optional[int] + model_config = ConfigDict(from_attributes=True) # Properties to return to client @@ -54,17 +61,66 @@ class Template(TemplateInDb): pass -class Task(BaseModel): - id: int +# Shared properties +class TaskBase(BaseModel): + date: Optional[date] + story: Optional[Annotated[str, StringConstraints(max_length=80)]] = None + description: Optional[Annotated[str, StringConstraints(max_length=8192)]] = None + task_type: Optional[Annotated[str, StringConstraints(max_length=40)]] = None + project_id: Optional[int] = None + user_id: Optional[int] = None + start_time: Optional[str] = None + end_time: Optional[str] = None + + @model_validator(mode="after") + @classmethod + def end_after_init_task(cls, data: Any) -> Any: + end_time = data.end + init_time = data.init + if end_time and init_time: + if end_time < init_time: + raise ValueError("End time must be greater than or equal to start time.") + if init_time < 0: + raise ValueError("Start time must be greater than or equal to 0.") + if end_time > 1439: + raise ValueError("End time must be less than or equal to 1439 (23:59).") + return data + return data + + +# Properties to receive on creation +class TaskNew(TaskBase): date: date + project_id: int + user_id: int + start_time: Annotated[str, StringConstraints(pattern=r"^[0-9]{2}:[0-9]{2}")] + end_time: Annotated[str, StringConstraints(pattern=r"^[0-9]{2}:[0-9]{2}")] + + @computed_field + @property + def init(self) -> int: + return time_string_to_int(self.start_time) + + @computed_field + @property + def end(self) -> int: + return time_string_to_int(self.end_time) + + +# Properties shared by models stored in db +class TaskInDb(TaskBase): + id: int init: int end: int - story: Optional[str] - description: Optional[str] - task_type: Optional[str] - project_id: int + model_config = ConfigDict(from_attributes=True) + + +# Properties to return to client +class Task(TaskInDb): project_name: str - customer_name: Optional[str] + customer_name: Optional[str] = None + - class Config: - orm_mode = True +class TaskValidate(BaseModel): + is_task_valid: bool + message: Optional[str] = None diff --git a/api/schemas/user.py b/api/schemas/user.py index e44dc6c45..a4c387704 100644 --- a/api/schemas/user.py +++ b/api/schemas/user.py @@ -1,24 +1,18 @@ -from pydantic import BaseModel +from pydantic import ConfigDict, BaseModel class User(BaseModel): id: int login: str - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class UserGroup(BaseModel): id: int name: str - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class UserRoles(BaseModel): role: UserGroup - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) diff --git a/api/services/config.py b/api/services/config.py new file mode 100644 index 000000000..3431e909e --- /dev/null +++ b/api/services/config.py @@ -0,0 +1,24 @@ +from datetime import date, timedelta +from services.main import AppService +from models.config import Config + + +class ConfigService(AppService): + def can_user_edit_task(self, task_date: date) -> bool: + config = self.db.query(Config).first() or None + if not config: + return True + day_limit_date = task_date + date_limit_date = task_date + day_limit_enabled = config.block_tasks_by_day_limit_enabled + date_limit_enabled = config.block_tasks_by_date_enabled + number_of_days_limit = config.block_tasks_by_day_limit_number_of_days + cutoff_date = config.block_tasks_by_date_date + # if no limits enabled, just allow editing + if not date_limit_enabled and not day_limit_enabled: + return True + if day_limit_enabled and number_of_days_limit is not None and number_of_days_limit > 0: + day_limit_date = date.today() + timedelta(days=number_of_days_limit) + if date_limit_enabled and cutoff_date is not None: + date_limit_date = cutoff_date + return task_date > day_limit_date or task_date > date_limit_date diff --git a/api/services/projects.py b/api/services/projects.py index 42c325ea5..4952952d2 100644 --- a/api/services/projects.py +++ b/api/services/projects.py @@ -12,3 +12,9 @@ def get_items(self, offset, limit, status) -> List[Project]: if status == "inactive": query = query.filter(Project.activation is False) return query.offset(offset).limit(limit).all() or [] + + def is_project_active(self, project_id) -> bool: + project = self.db.query(Project).where(Project.id == project_id).first() or None + if project is not None: + return project.is_active + return False diff --git a/api/services/timelog.py b/api/services/timelog.py index 1c1299fbc..3d6da874b 100644 --- a/api/services/timelog.py +++ b/api/services/timelog.py @@ -1,10 +1,10 @@ from typing import List -from datetime import date +from datetime import date, datetime from sqlalchemy import or_ from services.main import AppService from models.timelog import TaskType, Template, Task -from schemas.timelog import TemplateNew +from schemas.timelog import TemplateNew, TaskNew, TaskValidate class TaskTypeService(AppService): @@ -12,6 +12,9 @@ def get_items(self) -> List[TaskType]: task_types = self.db.query(TaskType).all() or [] return task_types + def slug_is_valid(self, slug: str) -> bool: + return any(self.db.query(TaskType).where(TaskType.slug == slug)) + class TemplateService(AppService): def get_user_templates(self, user_id: int) -> List[Template]: @@ -25,8 +28,8 @@ def create_template(self, template: TemplateNew) -> Template: name=template.name, story=template.story, task_type=template.task_type, - init_time=template.init_time, - end_time=template.end_time, + init=template.init, + end=template.end, user_id=template.user_id, project_id=template.project_id, is_global=template.is_global, @@ -48,3 +51,40 @@ def get_user_tasks(self, user_id: int, offset: int, limit: int, start: date, end or [] ) return tasks + + def create_task(self, task: TaskNew) -> Task: + new_task = Task( + date=task.date, + init=task.init, + end=task.end, + story=task.story, + description=task.description, + task_type=task.task_type, + updated_at=datetime.now(), + user_id=task.user_id, + project_id=task.project_id, + ) + self.db.add(new_task) + self.db.commit() + self.db.refresh(new_task) + return new_task + + def check_task_for_overlap(self, task: Task) -> TaskValidate: + validated_task = TaskValidate(is_task_valid=True, message="") + user_tasks_for_day = self.db.query(Task).where(Task.user_id == task.user_id, Task.date == task.date).all() or [] + if len(user_tasks_for_day) <= 0: + return validated_task + for user_task_for_day in user_tasks_for_day: + if task.init == user_task_for_day.init: + validated_task.is_task_valid = False + validated_task.message += f"You have already logged a task beginning at {user_task_for_day.start_time}." + if task.end == user_task_for_day.end: + validated_task.is_task_valid = False + validated_task.message += f"You have already logged a task ending at {user_task_for_day.end_time}." + if task.end > user_task_for_day.init and task.init < user_task_for_day.end: + validated_task.is_task_valid = False + validated_task.message += ( + f"Task from {task.start_time} to {task.end_time} overlaps an existing task from" + f" {user_task_for_day.start_time} to {user_task_for_day.end_time}." + ) + return validated_task