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 POST task endpoint #684

Merged
merged 1 commit into from
Oct 3, 2023
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
16 changes: 9 additions & 7 deletions api/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
10 changes: 10 additions & 0 deletions api/helpers/time.py
Original file line number Diff line number Diff line change
@@ -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))
21 changes: 19 additions & 2 deletions api/models/timelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"),)


Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
60 changes: 57 additions & 3 deletions api/routers/v1/timelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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):
anarute marked this conversation as resolved.
Show resolved Hide resolved
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
8 changes: 3 additions & 5 deletions api/schemas/customer.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
from pydantic import BaseModel
from pydantic import ConfigDict, BaseModel
from typing import Optional


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)
22 changes: 10 additions & 12 deletions api/schemas/project.py
Original file line number Diff line number Diff line change
@@ -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
anarute marked this conversation as resolved.
Show resolved Hide resolved
customer_id: int
area_id: int

class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
122 changes: 89 additions & 33 deletions api/schemas/timelog.py
Original file line number Diff line number Diff line change
@@ -1,70 +1,126 @@
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
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
dmtrek14 marked this conversation as resolved.
Show resolved Hide resolved


# 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
Loading