Skip to content

Commit

Permalink
Add POST task endpoint
Browse files Browse the repository at this point in the history
- Update Pydantic to v2 and make associated syntax changes to several models
- Add validation, computed fields, and hybrid properties to support task creation
- Add services and service methods to validate tasks
- Add/update schemas for tasks
  • Loading branch information
dmtrek14 committed Oct 2, 2023
1 parent 64e9609 commit 7fc47b1
Show file tree
Hide file tree
Showing 11 changed files with 269 additions and 77 deletions.
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
21 changes: 19 additions & 2 deletions api/models/timelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import relationship, Mapped
from sqlalchemy.ext.hybrid import hybrid_property
from datetime import timedelta
from models.project import Project
from models.user import User

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 str(timedelta(minutes=self.init))

@hybrid_property
def end_time(self):
return str(timedelta(minutes=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 str(timedelta(minutes=self.init)) if self.init else None

@hybrid_property
def end_time(self):
return str(timedelta(minutes=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):
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
customer_id: int
area_id: int

class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)
125 changes: 92 additions & 33 deletions api/schemas/timelog.py
Original file line number Diff line number Diff line change
@@ -1,70 +1,129 @@
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


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

hours, minutes = self.start_time.split(":")
return int(hours) * 60 + int(minutes)

@computed_field
@property
def end(self) -> Optional[int]:
if not self.end_time:
return None

hours, minutes = self.end_time.split(":")
return int(hours) * 60 + int(minutes)


# 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


# 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:
hours, minutes = self.start_time.split(":")
return int(hours) * 60 + int(minutes)

@computed_field
@property
def end(self) -> int:
hours, minutes = self.end_time.split(":")
return int(hours) * 60 + int(minutes)


# 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

0 comments on commit 7fc47b1

Please sign in to comment.