From ace2167ebd12032adf7a52061272e131d396e20e Mon Sep 17 00:00:00 2001 From: vogonwann Date: Sat, 31 Jan 2026 17:49:36 +0100 Subject: [PATCH] Initial commit --- __pycache__/crud.cpython-314.pyc | Bin 0 -> 8192 bytes __pycache__/db.cpython-314.pyc | Bin 0 -> 1086 bytes __pycache__/main.cpython-314.pyc | Bin 0 -> 12144 bytes __pycache__/models.cpython-314.pyc | Bin 0 -> 1882 bytes __pycache__/schemas.cpython-314.pyc | Bin 0 -> 4882 bytes crud.py | 125 +++++++++++++++++++ db.py | 19 +++ main.py | 185 ++++++++++++++++++++++++++++ models.py | 40 ++++++ schemas.py | 68 ++++++++++ worklog.db | Bin 0 -> 32768 bytes 11 files changed, 437 insertions(+) create mode 100644 __pycache__/crud.cpython-314.pyc create mode 100644 __pycache__/db.cpython-314.pyc create mode 100644 __pycache__/main.cpython-314.pyc create mode 100644 __pycache__/models.cpython-314.pyc create mode 100644 __pycache__/schemas.cpython-314.pyc create mode 100644 crud.py create mode 100644 db.py create mode 100644 main.py create mode 100644 models.py create mode 100644 schemas.py create mode 100644 worklog.db diff --git a/__pycache__/crud.cpython-314.pyc b/__pycache__/crud.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..45d377ade39a299a8d2410ec30b3115c79397fd3 GIT binary patch literal 8192 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 W_#tc9hyz;LH~_BT@iWj%fBJ83E~A0~ literal 0 HcmV?d00001 diff --git a/__pycache__/db.cpython-314.pyc b/__pycache__/db.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1647f5af01a47540256ece765fbfefdfb93833ec GIT binary patch literal 1086 zcmY*X%}*0S6rb5{x23dDAOQ{-5TT?2*$WXhYElCT307P_^kCU+x6^jRe$<(%2#HBC zMz0(_a`ooXqkjSl31ku@#*;S)Bwl^fkARcxoAct;|J;N&mfIQGBLgp>dCc*=!j(a3D+{4{m8*lu2=QPBk2eM{{aDCv1eFUi-A#2=F!|egmZwf25gKM8c-T z;>09=$?=H@lUKy&X)NN5Fy;ilXKfI=-OFCO4kveYU7yr91G?b`rO85GblGKMZIg{cGuV3fjMs{=DQm><~BQJv? zkuSA+s)rk*MMp(hQPUI`-uO*Q`-M351_8vDNjtcje0vfaC6WF9@v3_1J84~Xp?%;t+FzWLQXav6ES$sa$&K&Eh@oK6@=)x z^iBfjR8C67XEH09hneMBbJbWBT62fnU59bvGZQ9MM=RpXjb&a8Y|Et*d>v7v$LKvT zC{$hYfDS`IvcX<}I1OQpf1>dpXzZkOtQP;()_bfbkGn_rs{36xkJ~#hB5F*jX{Q}X xi5%hX@3?#S*2g;sc=SSnwK)$VC3PybvlvPa?Dc-0{kpop{Oqvv`P+E?^?xhw1*8A~ literal 0 HcmV?d00001 diff --git a/__pycache__/main.cpython-314.pyc b/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cc0e1e5d76d0bb9bdd115376f619291f46c8780b GIT binary patch literal 12144 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 literal 0 HcmV?d00001 diff --git a/crud.py b/crud.py new file mode 100644 index 0000000..fcc6e20 --- /dev/null +++ b/crud.py @@ -0,0 +1,125 @@ +from datetime import date +from typing import List, Optional + +from sqlalchemy import and_, desc, select +from sqlalchemy.orm import Session + +from models import Project, WorkLogEntry + + +def create_project(db: Session, name: str, description: Optional[str]) -> Project: + project = Project(name=name, description=description) + db.add(project) + db.commit() + db.refresh(project) + return project + + +def get_project(db: Session, project_id: int) -> Optional[Project]: + return db.get(Project, project_id) + + +def get_project_by_name(db: Session, name: str) -> Optional[Project]: + stmt = select(Project).where(Project.name == name) + return db.execute(stmt).scalars().first() + + +def list_projects(db: Session, limit: int, offset: int) -> List[Project]: + stmt = select(Project).order_by(desc(Project.id)).limit(limit).offset(offset) + return db.execute(stmt).scalars().all() + + +def update_project( + db: Session, + project: Project, + name: Optional[str], + description: Optional[str], +) -> Project: + if name is not None: + project.name = name + if description is not None: + project.description = description + db.commit() + db.refresh(project) + return project + + +def delete_project(db: Session, project: Project) -> None: + db.delete(project) + db.commit() + + +def create_work_log( + db: Session, + project_id: int, + log_date: date, + hours: float, + description: str, +) -> WorkLogEntry: + entry = WorkLogEntry( + project_id=project_id, + date=log_date, + hours=hours, + description=description, + ) + db.add(entry) + db.commit() + db.refresh(entry) + return entry + + +def get_work_log(db: Session, log_id: int) -> Optional[WorkLogEntry]: + return db.get(WorkLogEntry, log_id) + + +def list_work_logs(db: Session, limit: int, offset: int) -> List[WorkLogEntry]: + stmt = ( + select(WorkLogEntry) + .order_by(desc(WorkLogEntry.date), desc(WorkLogEntry.id)) + .limit(limit) + .offset(offset) + ) + return db.execute(stmt).scalars().all() + + +def list_work_logs_by_range( + db: Session, + start_date: date, + end_date: date, + limit: int, + offset: int, +) -> List[WorkLogEntry]: + stmt = ( + select(WorkLogEntry) + .where(and_(WorkLogEntry.date >= start_date, WorkLogEntry.date <= end_date)) + .order_by(desc(WorkLogEntry.date), desc(WorkLogEntry.id)) + .limit(limit) + .offset(offset) + ) + return db.execute(stmt).scalars().all() + + +def update_work_log( + db: Session, + entry: WorkLogEntry, + project_id: Optional[int], + log_date: Optional[date], + hours: Optional[float], + description: Optional[str], +) -> WorkLogEntry: + if project_id is not None: + entry.project_id = project_id + if log_date is not None: + entry.date = log_date + if hours is not None: + entry.hours = hours + if description is not None: + entry.description = description + db.commit() + db.refresh(entry) + return entry + + +def delete_work_log(db: Session, entry: WorkLogEntry) -> None: + db.delete(entry) + db.commit() diff --git a/db.py b/db.py new file mode 100644 index 0000000..c40aeb8 --- /dev/null +++ b/db.py @@ -0,0 +1,19 @@ +from sqlalchemy import create_engine, event +from sqlalchemy.orm import sessionmaker + +DATABASE_URL = "sqlite:///./worklog.db" + +engine = create_engine( + DATABASE_URL, + connect_args={"check_same_thread": False}, + future=True, +) + + +@event.listens_for(engine, "connect") +def _set_sqlite_pragma(dbapi_connection, connection_record) -> None: + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + +SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False, future=True) diff --git a/main.py b/main.py new file mode 100644 index 0000000..5e2ec21 --- /dev/null +++ b/main.py @@ -0,0 +1,185 @@ +import calendar +from datetime import date, timedelta +from typing import Generator, List + +from fastapi import Depends, FastAPI, HTTPException, Query, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +import crud +from db import SessionLocal, engine +from models import Base +from schemas import ( + ProjectCreate, + ProjectRead, + ProjectUpdate, + WorkLogCreate, + WorkLogRead, + WorkLogUpdate, +) + +app = FastAPI(title="Work Log API") + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() + + +@app.on_event("startup") +def on_startup() -> None: + Base.metadata.create_all(bind=engine) + + +@app.post("/projects", response_model=ProjectRead, status_code=status.HTTP_201_CREATED) +def create_project(payload: ProjectCreate, db: Session = Depends(get_db)) -> ProjectRead: + existing = crud.get_project_by_name(db, payload.name) + if existing: + raise HTTPException(status_code=409, detail="project name already exists") + try: + return crud.create_project(db, payload.name, payload.description) + except IntegrityError: + raise HTTPException(status_code=409, detail="project name already exists") + + +@app.get("/projects", response_model=List[ProjectRead]) +def list_projects( + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + db: Session = Depends(get_db), +) -> List[ProjectRead]: + return crud.list_projects(db, limit, offset) + + +@app.get("/projects/{project_id}", response_model=ProjectRead) +def get_project(project_id: int, db: Session = Depends(get_db)) -> ProjectRead: + project = crud.get_project(db, project_id) + if not project: + raise HTTPException(status_code=404, detail="project not found") + return project + + +@app.put("/projects/{project_id}", response_model=ProjectRead) +def update_project( + project_id: int, + payload: ProjectUpdate, + db: Session = Depends(get_db), +) -> ProjectRead: + project = crud.get_project(db, project_id) + if not project: + raise HTTPException(status_code=404, detail="project not found") + if payload.name: + existing = crud.get_project_by_name(db, payload.name) + if existing and existing.id != project_id: + raise HTTPException(status_code=409, detail="project name already exists") + return crud.update_project(db, project, payload.name, payload.description) + + +@app.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_project(project_id: int, db: Session = Depends(get_db)) -> None: + project = crud.get_project(db, project_id) + if not project: + raise HTTPException(status_code=404, detail="project not found") + crud.delete_project(db, project) + return None + + +@app.post("/work-logs", response_model=WorkLogRead, status_code=status.HTTP_201_CREATED) +def create_work_log(payload: WorkLogCreate, db: Session = Depends(get_db)) -> WorkLogRead: + project = crud.get_project(db, payload.project_id) + if not project: + raise HTTPException(status_code=404, detail="project not found") + return crud.create_work_log( + db, + payload.project_id, + payload.date, + payload.hours, + payload.description, + ) + + +@app.get("/work-logs", response_model=List[WorkLogRead]) +def list_work_logs( + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + db: Session = Depends(get_db), +) -> List[WorkLogRead]: + return crud.list_work_logs(db, limit, offset) + + +@app.get("/work-logs/{log_id}", response_model=WorkLogRead) +def get_work_log(log_id: int, db: Session = Depends(get_db)) -> WorkLogRead: + entry = crud.get_work_log(db, log_id) + if not entry: + raise HTTPException(status_code=404, detail="work log not found") + return entry + + +@app.put("/work-logs/{log_id}", response_model=WorkLogRead) +def update_work_log( + log_id: int, + payload: WorkLogUpdate, + db: Session = Depends(get_db), +) -> WorkLogRead: + entry = crud.get_work_log(db, log_id) + if not entry: + raise HTTPException(status_code=404, detail="work log not found") + if payload.project_id is not None: + project = crud.get_project(db, payload.project_id) + if not project: + raise HTTPException(status_code=404, detail="project not found") + return crud.update_work_log( + db, + entry, + payload.project_id, + payload.date, + payload.hours, + payload.description, + ) + + +@app.delete("/work-logs/{log_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_work_log(log_id: int, db: Session = Depends(get_db)) -> None: + entry = crud.get_work_log(db, log_id) + if not entry: + raise HTTPException(status_code=404, detail="work log not found") + crud.delete_work_log(db, entry) + return None + + +@app.get("/work-logs/day", response_model=List[WorkLogRead]) +def work_logs_for_day( + date_param: date = Query(..., alias="date"), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + db: Session = Depends(get_db), +) -> List[WorkLogRead]: + return crud.list_work_logs_by_range(db, date_param, date_param, limit, offset) + + +@app.get("/work-logs/week", response_model=List[WorkLogRead]) +def work_logs_for_week( + date_param: date = Query(..., alias="date"), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + db: Session = Depends(get_db), +) -> List[WorkLogRead]: + start = date_param - timedelta(days=date_param.weekday()) + end = start + timedelta(days=6) + return crud.list_work_logs_by_range(db, start, end, limit, offset) + + +@app.get("/work-logs/month", response_model=List[WorkLogRead]) +def work_logs_for_month( + date_param: date = Query(..., alias="date"), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + db: Session = Depends(get_db), +) -> List[WorkLogRead]: + last_day = calendar.monthrange(date_param.year, date_param.month)[1] + start = date_param.replace(day=1) + end = date_param.replace(day=last_day) + return crud.list_work_logs_by_range(db, start, end, limit, offset) diff --git a/models.py b/models.py new file mode 100644 index 0000000..b75233d --- /dev/null +++ b/models.py @@ -0,0 +1,40 @@ +from datetime import datetime + +from sqlalchemy import Column, Date, DateTime, Float, ForeignKey, Integer, String +from sqlalchemy.orm import declarative_base, relationship + +Base = declarative_base() + + +class Project(Base): + __tablename__ = "projects" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(200), unique=True, nullable=False, index=True) + description = Column(String(1000), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + work_logs = relationship( + "WorkLogEntry", + back_populates="project", + cascade="all, delete-orphan", + passive_deletes=True, + ) + + +class WorkLogEntry(Base): + __tablename__ = "work_logs" + + id = Column(Integer, primary_key=True, index=True) + project_id = Column( + Integer, + ForeignKey("projects.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + date = Column(Date, nullable=False, index=True) + hours = Column(Float, nullable=False) + description = Column(String(2000), nullable=False) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + project = relationship("Project", back_populates="work_logs") diff --git a/schemas.py b/schemas.py new file mode 100644 index 0000000..96e6d2a --- /dev/null +++ b/schemas.py @@ -0,0 +1,68 @@ +from datetime import date, datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, field_validator + + +def _validate_half_hours(value: float) -> float: + if value <= 0: + raise ValueError("hours must be greater than 0") + scaled = value * 2 + if abs(scaled - round(scaled)) > 1e-6: + raise ValueError("hours must be in 0.5 increments") + return value + + +class ProjectBase(BaseModel): + name: str + description: Optional[str] = None + + +class ProjectCreate(ProjectBase): + pass + + +class ProjectUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + + +class ProjectRead(ProjectBase): + id: int + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class WorkLogBase(BaseModel): + project_id: int + date: date + hours: float + description: str + + _validate_hours = field_validator("hours")(_validate_half_hours) + + +class WorkLogCreate(WorkLogBase): + pass + + +class WorkLogUpdate(BaseModel): + project_id: Optional[int] = None + date: Optional[date] = None + hours: Optional[float] = None + description: Optional[str] = None + + @field_validator("hours") + @classmethod + def validate_hours(cls, value: Optional[float]) -> Optional[float]: + if value is None: + return value + return _validate_half_hours(value) + + +class WorkLogRead(WorkLogBase): + id: int + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/worklog.db b/worklog.db new file mode 100644 index 0000000000000000000000000000000000000000..08eb98c2aaa7eab50ade9ba9583eb2ea608e671a GIT binary patch literal 32768 zcmeI(O-~a+7{Kve3SHU&^+I}TI*FHV6l@Ec05NfG7i=tBSc-voXewQ01-jT)CGiCD z>cNYj%TM8FFoqa+)^6XR2ah)YN$E~!W}lh)J$sn~d%I<~#W`pE6N>3Rh1_~ zD2g)M{qZkB(fs%6Ze3o~f%>@Wtg`&&*Sz1W%{^8Ug@qpr-{ueIe$RdNKT;4t009IL zKmY**5J2F660o&s@?KhX-DbnNa4%{fz1B(X)O*vexs6_ZwqTn^)f6SGXdZ}>QKDjr z-bj&g8~RC9OKzmq%h=7J=PmE8bJQ6Ou8j}gA3ZWjze7Kmi6$SU)ps}NYScSUZ(-5# zv&eBH@nw^KwK1(F(`ogy*{Q!e?T6~coax4_8c)mSpg}z5HhiqAxnK5Dt=9oK7$7lvJUX4sj0Hk;LlMjf>r ze>07me>9YltEKJX=sR|4+prJCbMr7ro?PZ;#WqV@mTb%Pm#hoh+%#>|Dwuns+uRQB zk$Oe5Z2Ai?7<&byXvTFb5>4tU)d}+w-r%P`tWJ;SB<%3V$dKX3V2+nl(d1G}{d6N< zvwr4;BO~32`&Mapf8coy`)hLWaA?`0U#_cKGL=%Vwt`fL!^7*;P@=Ws2@*Vyse z$3^FZbLyR+In7Q!n}4*F%`L6uL@vLcUt3>&xV*NS&9CI+{=U@2PsM*x5I_I{1Q0*~ z0R#|0009ILKw!cIw8&yieo656{C}+^t|x4RYzF}Z5I_I{1Q0*~0R#|0009I7fk-44 zlYjs3|IfB*srAb