diff --git a/pyproject.toml b/pyproject.toml index 4342753..038db48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,12 +7,15 @@ name = "employee-manager" version = "1.0.0" description = "Application to manage the employees schedule." requires-python = ">=3.10" - -[project.optional-dependencies] -menu = ["textual"] +dependencies = [ + "fastapi>=0.118", + "psycopg[binary]>=3.2", + "python-dotenv>=1.0", + "uvicorn[standard]>=0.37", +] [project.scripts] -scheduler = "scheduler.menu:main" +scheduler = "scheduler.__main__:main" [tool.setuptools.packages.find] where=["src"] diff --git a/src/scheduler/__init__.py b/src/scheduler/__init__.py index e69de29..e3f54df 100644 --- a/src/scheduler/__init__.py +++ b/src/scheduler/__init__.py @@ -0,0 +1,3 @@ +__version__ = "1.0.0" + +__all__ = ["__version__"] diff --git a/src/scheduler/__main__.py b/src/scheduler/__main__.py index e69de29..23d4a42 100644 --- a/src/scheduler/__main__.py +++ b/src/scheduler/__main__.py @@ -0,0 +1,13 @@ +import os + +import uvicorn + + +def main(): + host = os.environ.get("APP_HOST", "127.0.0.1") + port = int(os.environ.get("APP_PORT", "5006")) + uvicorn.run("scheduler.main:app", host=host, port=port) + + +if __name__ == "__main__": + main() diff --git a/src/scheduler/api/__init__.py b/src/scheduler/api/__init__.py new file mode 100644 index 0000000..f380ee1 --- /dev/null +++ b/src/scheduler/api/__init__.py @@ -0,0 +1,8 @@ +from . import company_routes, employee_routes, health_routes, schedule_routes + +__all__ = [ + "company_routes", + "employee_routes", + "health_routes", + "schedule_routes", +] diff --git a/src/scheduler/api/company_routes.py b/src/scheduler/api/company_routes.py new file mode 100644 index 0000000..6abc51b --- /dev/null +++ b/src/scheduler/api/company_routes.py @@ -0,0 +1,35 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from scheduler.api.dependencies import get_company_service +from scheduler.core.schemas.company import Company, CreateCompanyRequest +from scheduler.core.services.company_service import CompanyService + +router = APIRouter(prefix="/companies", tags=["companies"]) + + +@router.get("", response_model=list[Company]) +def list_companies(service: Annotated[CompanyService, Depends(get_company_service)]): + return service.list_companies() + + +@router.post("", response_model=Company) +def create_company(payload: CreateCompanyRequest, service: Annotated[CompanyService, Depends(get_company_service)]): + return service.create_company(payload.name) + + +@router.get("/{id}", response_model=Company) +def get_company(id: int, service: Annotated[CompanyService, Depends(get_company_service)]): + company = service.get_company(id) + if company is None: + raise HTTPException(status_code=404, detail="Company not found") + return company + + +@router.delete("/{id}", response_model=Company) +def delete_company(id: int, service: Annotated[CompanyService, Depends(get_company_service)]): + company = service.delete_company(id) + if company is None: + raise HTTPException(status_code=404, detail="Company not found") + return company diff --git a/src/scheduler/api/dependencies.py b/src/scheduler/api/dependencies.py new file mode 100644 index 0000000..04ea107 --- /dev/null +++ b/src/scheduler/api/dependencies.py @@ -0,0 +1,50 @@ +from collections.abc import Generator + +from dotenv import load_dotenv + +from scheduler.core.services.company_service import CompanyService +from scheduler.core.services.employee_service import EmployeeService +from scheduler.core.services.schedule_service import ScheduleService +from scheduler.database import CompanyRepository, Database, EmployeeRepository, ScheduleRepository + + + +def get_database() -> Database: + load_dotenv() + return Database() + + +def get_db() -> Generator: + connection = get_database().get_connection() + try: + yield connection + finally: + connection.close() + + +def get_company_service() -> Generator: + connection = get_database().get_connection() + try: + yield CompanyService(CompanyRepository(connection)) + finally: + connection.close() + + +def get_employee_service() -> Generator: + connection = get_database().get_connection() + try: + yield EmployeeService(EmployeeRepository(connection), CompanyRepository(connection)) + finally: + connection.close() + + +def get_schedule_service() -> Generator: + connection = get_database().get_connection() + try: + yield ScheduleService( + ScheduleRepository(connection), + EmployeeRepository(connection), + CompanyRepository(connection), + ) + finally: + connection.close() diff --git a/src/scheduler/api/employee_routes.py b/src/scheduler/api/employee_routes.py new file mode 100644 index 0000000..6917967 --- /dev/null +++ b/src/scheduler/api/employee_routes.py @@ -0,0 +1,109 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from scheduler.api.dependencies import get_employee_service +from scheduler.core.schemas.company import CompanyUser +from scheduler.core.schemas.employee import ( + CreateEmployeeRequest, + EmployeeNamesResponse, + EmployeeResponse, + RegisterPreferenceRequest, + UpdateEmployeeRequest, + WeeklyPreferenceRequest, +) +from scheduler.core.services.employee_service import EmployeeService + +router = APIRouter(tags=["employees"]) + + +@router.get("/employees", response_model=list[EmployeeResponse]) +def list_employees(service: Annotated[EmployeeService, Depends(get_employee_service)]): + return service.list_employees() + + +@router.post("/employees", response_model=EmployeeResponse) +def create_employee(payload: CreateEmployeeRequest, service: Annotated[EmployeeService, Depends(get_employee_service)]): + employee = service.create_employee(payload.company_id, payload.name, payload.last_name, payload.preference) + if employee is None: + raise HTTPException(status_code=404, detail="Company not found") + return employee + + +@router.put("/employees/{id}", response_model=EmployeeResponse) +def update_employee( + id: int, + payload: UpdateEmployeeRequest, + service: Annotated[EmployeeService, Depends(get_employee_service)], +): + employee = service.update_employee(id, payload.name, payload.last_name, payload.preference, payload.company_id) + if employee is None: + raise HTTPException(status_code=404, detail="Employee or company not found") + return employee + + +@router.patch("/employees/{id}", response_model=EmployeeResponse) +def patch_employee( + id: int, + payload: UpdateEmployeeRequest, + service: Annotated[EmployeeService, Depends(get_employee_service)], +): + employee = service.update_employee(id, payload.name, payload.last_name, payload.preference, payload.company_id) + if employee is None: + raise HTTPException(status_code=404, detail="Employee or company not found") + return employee + + +@router.delete("/employees/{id}", response_model=EmployeeResponse) +def delete_employee(id: int, service: Annotated[EmployeeService, Depends(get_employee_service)]): + employee = service.delete_employee(id) + if employee is None: + raise HTTPException(status_code=404, detail="Employee not found") + return employee + + +@router.put("/employees/{id}/preference", response_model=EmployeeResponse) +def register_employee_preference( + id: int, + payload: RegisterPreferenceRequest, + service: Annotated[EmployeeService, Depends(get_employee_service)], +): + employee = service.update_preference(id, payload.preference) + if employee is None: + raise HTTPException(status_code=404, detail="Employee not found") + return employee + + +@router.put("/employees/{id}/weekly-preferences", response_model=EmployeeResponse) +def register_weekly_employee_preferences( + id: int, + payload: WeeklyPreferenceRequest, + service: Annotated[EmployeeService, Depends(get_employee_service)], +): + employee = service.save_weekly_preferences(id, payload.preferences) + if employee is None: + raise HTTPException(status_code=404, detail="Employee not found") + return employee + + +@router.post("/companies/{company_id}/employees/{employee_id}", response_model=CompanyUser) +def assign_employee_to_company( + company_id: int, + employee_id: int, + service: Annotated[EmployeeService, Depends(get_employee_service)], +): + company_user = service.assign_to_company(company_id, employee_id) + if company_user is None: + raise HTTPException(status_code=404, detail="Company or employee not found") + return company_user + + +@router.get("/companies/{company_name}/employees/names", response_model=EmployeeNamesResponse) +def get_employee_names_by_company( + company_name: str, + service: Annotated[EmployeeService, Depends(get_employee_service)], +): + employees = service.get_names_by_company_name(company_name) + if employees is None: + raise HTTPException(status_code=404, detail="Company not found") + return {"employees": employees} diff --git a/src/scheduler/api/health_routes.py b/src/scheduler/api/health_routes.py new file mode 100644 index 0000000..a38a2a4 --- /dev/null +++ b/src/scheduler/api/health_routes.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter + +from scheduler.core.schemas.health import HealthResponse + +router = APIRouter(tags=["health"]) + + +@router.get("/health", response_model=HealthResponse) +def health_check(): + return {"status": "ok"} diff --git a/src/scheduler/api/schedule_routes.py b/src/scheduler/api/schedule_routes.py new file mode 100644 index 0000000..86ea4f0 --- /dev/null +++ b/src/scheduler/api/schedule_routes.py @@ -0,0 +1,68 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from scheduler.api.dependencies import get_schedule_service +from scheduler.core.schemas.schedule import ( + ClockInRequest, + ClockOutRequest, + CreateScheduleRequest, + GenerateWeeklyScheduleRequest, + ScheduleResponse, + WeeklyScheduleResponse, +) +from scheduler.core.services.schedule_service import ScheduleService + +router = APIRouter(tags=["schedules"]) + + +@router.post("/schedules", response_model=ScheduleResponse) +def create_schedule(payload: CreateScheduleRequest, service: Annotated[ScheduleService, Depends(get_schedule_service)]): + schedule = service.create_schedule(payload.user_id, payload.enter_time, payload.day_of_week, payload.out_time) + if schedule is None: + raise HTTPException(status_code=404, detail="Employee not found") + return schedule + + +@router.post("/employees/{id}/clock-in", response_model=ScheduleResponse) +def clock_in( + id: int, + payload: ClockInRequest, + service: Annotated[ScheduleService, Depends(get_schedule_service)], +): + schedule = service.clock_in( + id, + day_of_week=payload.day_of_week, + enter_time=payload.enter_time, + ) + if schedule is None: + raise HTTPException(status_code=404, detail="Employee not found") + return schedule + + +@router.put("/employees/{id}/clock-out", response_model=ScheduleResponse) +def clock_out( + id: int, + payload: ClockOutRequest, + service: Annotated[ScheduleService, Depends(get_schedule_service)], +): + schedule = service.clock_out(id, payload.day_of_week, payload.out_time) + if schedule is None: + raise HTTPException(status_code=404, detail="Schedule not found") + return schedule + + +@router.get("/schedules/weekly", response_model=WeeklyScheduleResponse) +def get_weekly_schedule(service: Annotated[ScheduleService, Depends(get_schedule_service)]): + return service.get_weekly_schedule() + + +@router.post("/schedules/weekly/generate", response_model=WeeklyScheduleResponse) +def generate_weekly_schedule( + payload: GenerateWeeklyScheduleRequest, + service: Annotated[ScheduleService, Depends(get_schedule_service)], +): + schedule = service.generate_weekly_schedule(payload.company_id) + if schedule is None: + raise HTTPException(status_code=404, detail="Company not found") + return schedule diff --git a/src/scheduler/app.py b/src/scheduler/app.py new file mode 100644 index 0000000..28f2e2d --- /dev/null +++ b/src/scheduler/app.py @@ -0,0 +1,46 @@ +from contextlib import asynccontextmanager + +from dotenv import load_dotenv +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from scheduler.api import company_routes, employee_routes, health_routes, schedule_routes +from scheduler.database import Database + + +@asynccontextmanager +async def lifespan(app: FastAPI): + Database().initialize_schema() + yield + + +def create_app() -> FastAPI: + load_dotenv() + + app = FastAPI( + title="manager_employees_python", + lifespan=lifespan, + openapi_url="/api-docs/openapi.json", + docs_url="/swagger-ui/", + ) + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + app.include_router(health_routes.router) + app.include_router(company_routes.router) + app.include_router(employee_routes.router) + app.include_router(schedule_routes.router) + + @app.get("/api-docs/openai.json", include_in_schema=False) + def openai_json_alias(): + return app.openapi() + + return app + + +app = create_app() diff --git a/src/scheduler/tui/__init__.py b/src/scheduler/core/__init__.py similarity index 100% rename from src/scheduler/tui/__init__.py rename to src/scheduler/core/__init__.py diff --git a/src/scheduler/core/enums/__init__.py b/src/scheduler/core/enums/__init__.py new file mode 100644 index 0000000..ef53d3e --- /dev/null +++ b/src/scheduler/core/enums/__init__.py @@ -0,0 +1,4 @@ +from scheduler.core.enums.day_of_week import DayOfWeek +from scheduler.core.enums.work_time import WorkTime + +__all__ = ["DayOfWeek", "WorkTime"] diff --git a/src/scheduler/core/enums/day_of_week.py b/src/scheduler/core/enums/day_of_week.py new file mode 100644 index 0000000..2f6f190 --- /dev/null +++ b/src/scheduler/core/enums/day_of_week.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class DayOfWeek(str, Enum): + SUNDAY = "sunday" + MONDAY = "monday" + TUESDAY = "tuesday" + WEDNESDAY = "wednesday" + THURSDAY = "thursday" + FRIDAY = "friday" + SATURDAY = "saturday" diff --git a/src/scheduler/core/enums/work_time.py b/src/scheduler/core/enums/work_time.py new file mode 100644 index 0000000..b25d715 --- /dev/null +++ b/src/scheduler/core/enums/work_time.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class WorkTime(str, Enum): + MORNING = "morning" + AFTERNOON = "afternoon" + EVENING = "evening" diff --git a/src/scheduler/tui/screens/__init__.py b/src/scheduler/core/schemas/__init__.py similarity index 100% rename from src/scheduler/tui/screens/__init__.py rename to src/scheduler/core/schemas/__init__.py diff --git a/src/scheduler/core/schemas/company.py b/src/scheduler/core/schemas/company.py new file mode 100644 index 0000000..ff6b091 --- /dev/null +++ b/src/scheduler/core/schemas/company.py @@ -0,0 +1,22 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class CreateCompanyRequest(BaseModel): + name: str + + +class Company(BaseModel): + id: int + name: str + date_created: datetime + date_updated: datetime + + +class CompanyUser(BaseModel): + id: int + user_id: int + company_id: int + date_created: datetime + date_updated: datetime diff --git a/src/scheduler/core/schemas/employee.py b/src/scheduler/core/schemas/employee.py new file mode 100644 index 0000000..d61ae6d --- /dev/null +++ b/src/scheduler/core/schemas/employee.py @@ -0,0 +1,44 @@ +from pydantic import BaseModel + +from scheduler.core.enums import DayOfWeek, WorkTime + + +class CreateEmployeeRequest(BaseModel): + company_id: int + name: str + last_name: str + preference: WorkTime + + +class RegisterPreferenceRequest(BaseModel): + preference: WorkTime + + +class DayPreferenceRequest(BaseModel): + day_of_week: DayOfWeek + first: WorkTime + second: WorkTime + third: WorkTime + + +class WeeklyPreferenceRequest(BaseModel): + preferences: list[DayPreferenceRequest] + + +class EmployeeNamesResponse(BaseModel): + employees: list[str] + + +class UpdateEmployeeRequest(BaseModel): + company_id: int | None = None + name: str + last_name: str + preference: WorkTime + + +class EmployeeResponse(BaseModel): + id: int + company_id: int + name: str + last_name: str + preference: WorkTime diff --git a/src/scheduler/core/schemas/health.py b/src/scheduler/core/schemas/health.py new file mode 100644 index 0000000..a3ef630 --- /dev/null +++ b/src/scheduler/core/schemas/health.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class HealthResponse(BaseModel): + status: str diff --git a/src/scheduler/core/schemas/schedule.py b/src/scheduler/core/schemas/schedule.py new file mode 100644 index 0000000..686b421 --- /dev/null +++ b/src/scheduler/core/schemas/schedule.py @@ -0,0 +1,38 @@ +from datetime import datetime + +from pydantic import BaseModel + +from scheduler.core.enums import DayOfWeek + + +class CreateScheduleRequest(BaseModel): + user_id: int + enter_time: datetime + day_of_week: DayOfWeek + out_time: datetime + + +class ClockInRequest(BaseModel): + day_of_week: DayOfWeek + enter_time: datetime + + +class ClockOutRequest(BaseModel): + day_of_week: DayOfWeek + out_time: datetime + + +class GenerateWeeklyScheduleRequest(BaseModel): + company_id: int + + +class ScheduleResponse(BaseModel): + id: int + user_id: int + enter_time: datetime + day_of_week: DayOfWeek + out_time: datetime | None = None + + +class WeeklyScheduleResponse(BaseModel): + schedules: list[ScheduleResponse] diff --git a/src/scheduler/database/employees.json b/src/scheduler/core/services/__init__.py similarity index 100% rename from src/scheduler/database/employees.json rename to src/scheduler/core/services/__init__.py diff --git a/src/scheduler/core/services/company_service.py b/src/scheduler/core/services/company_service.py new file mode 100644 index 0000000..e32e2d4 --- /dev/null +++ b/src/scheduler/core/services/company_service.py @@ -0,0 +1,18 @@ +from scheduler.database.company_repository import CompanyRepository + + +class CompanyService: + def __init__(self, repository: CompanyRepository): + self.repository = repository + + def list_companies(self): + return self.repository.list() + + def create_company(self, name: str): + return self.repository.create(name) + + def get_company(self, company_id: int): + return self.repository.get(company_id) + + def delete_company(self, company_id: int): + return self.repository.delete(company_id) diff --git a/src/scheduler/core/services/employee_service.py b/src/scheduler/core/services/employee_service.py new file mode 100644 index 0000000..603b3b6 --- /dev/null +++ b/src/scheduler/core/services/employee_service.py @@ -0,0 +1,57 @@ +from scheduler.database.company_repository import CompanyRepository +from scheduler.database.employee_repository import EmployeeRepository + + +class EmployeeService: + def __init__(self, employee_repository: EmployeeRepository, company_repository: CompanyRepository): + self.employee_repository = employee_repository + self.company_repository = company_repository + + def list_employees(self): + return self.employee_repository.list() + + def create_employee(self, company_id: int, name: str, last_name: str, preference: str): + if self.company_repository.get(company_id) is None: + return None + return self.employee_repository.create(company_id, name, last_name, preference) + + def delete_employee(self, employee_id: int): + return self.employee_repository.delete(employee_id) + + def update_preference(self, employee_id: int, preference: str): + return self.employee_repository.update_preference(employee_id, preference) + + def update_employee( + self, + employee_id: int, + name: str, + last_name: str, + preference: str, + company_id: int | None, + ): + if self.employee_repository.get(employee_id) is None: + return None + if company_id is not None and self.company_repository.get(company_id) is None: + return None + updated = self.employee_repository.update(employee_id, name, last_name, preference) + if company_id is not None: + updated = self.employee_repository.update_company(employee_id, company_id) + return updated + + def assign_to_company(self, company_id: int, employee_id: int): + if self.company_repository.get(company_id) is None: + return None + if self.employee_repository.get(employee_id) is None: + return None + return self.employee_repository.assign_to_company(company_id, employee_id) + + def get_names_by_company_name(self, company_name: str): + company = self.company_repository.get_by_name(company_name) + if company is None: + return None + return self.employee_repository.get_names_by_company(company["id"]) + + def save_weekly_preferences(self, employee_id: int, preferences: list): + if self.employee_repository.get(employee_id) is None: + return None + return self.employee_repository.save_weekly_preferences(employee_id, preferences) diff --git a/src/scheduler/core/services/schedule_service.py b/src/scheduler/core/services/schedule_service.py new file mode 100644 index 0000000..3e44ba4 --- /dev/null +++ b/src/scheduler/core/services/schedule_service.py @@ -0,0 +1,46 @@ +from datetime import datetime + +from scheduler.database.company_repository import CompanyRepository +from scheduler.database.employee_repository import EmployeeRepository +from scheduler.database.schedule_repository import ScheduleRepository + + +class ScheduleService: + def __init__( + self, + schedule_repository: ScheduleRepository, + employee_repository: EmployeeRepository, + company_repository: CompanyRepository, + ): + self.schedule_repository = schedule_repository + self.employee_repository = employee_repository + self.company_repository = company_repository + + def create_schedule(self, user_id: int, enter_time: datetime, day_of_week: str, out_time: datetime): + if self.employee_repository.get(user_id) is None: + return None + return self.schedule_repository.create(user_id, enter_time, day_of_week, out_time) + + def clock_in( + self, + user_id: int, + day_of_week: str, + enter_time: datetime, + ): + employee = self.employee_repository.get(user_id) + if employee is None: + return None + return self.schedule_repository.create(user_id, enter_time, day_of_week, None) + + def clock_out(self, user_id: int, day_of_week: str, out_time: datetime): + if self.employee_repository.get(user_id) is None: + return None + return self.schedule_repository.update_clock_out(user_id, day_of_week, out_time) + + def get_weekly_schedule(self, company_id: int | None = None): + return {"schedules": self.schedule_repository.list_weekly(company_id)} + + def generate_weekly_schedule(self, company_id: int): + if self.company_repository.get(company_id) is None: + return None + return self.get_weekly_schedule(company_id) diff --git a/src/scheduler/database/__init__.py b/src/scheduler/database/__init__.py index e69de29..c8f6858 100644 --- a/src/scheduler/database/__init__.py +++ b/src/scheduler/database/__init__.py @@ -0,0 +1,6 @@ +from scheduler.database.company_repository import CompanyRepository +from scheduler.database.database import Database +from scheduler.database.employee_repository import EmployeeRepository +from scheduler.database.schedule_repository import ScheduleRepository + +__all__ = ["CompanyRepository", "Database", "EmployeeRepository", "ScheduleRepository"] diff --git a/src/scheduler/database/company_repository.py b/src/scheduler/database/company_repository.py new file mode 100644 index 0000000..c07451d --- /dev/null +++ b/src/scheduler/database/company_repository.py @@ -0,0 +1,66 @@ +class CompanyRepository: + def __init__(self, connection): + self.connection = connection + + def list(self): + with self.connection.cursor() as cursor: + cursor.execute( + """ + SELECT id, name, date_created, date_updated + FROM companies + ORDER BY id + """ + ) + return cursor.fetchall() + + def create(self, name: str): + with self.connection.cursor() as cursor: + cursor.execute( + """ + INSERT INTO companies (name) + VALUES (%s) + RETURNING id, name, date_created, date_updated + """, + (name,), + ) + company = cursor.fetchone() + self.connection.commit() + return company + + def get(self, company_id: int): + with self.connection.cursor() as cursor: + cursor.execute( + """ + SELECT id, name, date_created, date_updated + FROM companies + WHERE id = %s + """, + (company_id,), + ) + return cursor.fetchone() + + def get_by_name(self, name: str): + with self.connection.cursor() as cursor: + cursor.execute( + """ + SELECT id, name, date_created, date_updated + FROM companies + WHERE name = %s + """, + (name,), + ) + return cursor.fetchone() + + def delete(self, company_id: int): + with self.connection.cursor() as cursor: + cursor.execute( + """ + DELETE FROM companies + WHERE id = %s + RETURNING id, name, date_created, date_updated + """, + (company_id,), + ) + company = cursor.fetchone() + self.connection.commit() + return company diff --git a/src/scheduler/database/database.py b/src/scheduler/database/database.py new file mode 100644 index 0000000..2623d49 --- /dev/null +++ b/src/scheduler/database/database.py @@ -0,0 +1,105 @@ +import os + +import psycopg +from psycopg.rows import dict_row + + +DEFAULT_DATABASE_URL = "postgres://carlos:password@localhost/rust_manager_employees" + + +class Database: + def __init__(self, database_url: str | None = None): + self.database_url = database_url or os.environ.get("DATABASE_URL", DEFAULT_DATABASE_URL) + + def get_connection(self): + return psycopg.connect(self.database_url, row_factory=dict_row) + + def initialize_schema(self): + with self.get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + DO $$ + BEGIN + CREATE TYPE work_time AS ENUM ('morning', 'afternoon', 'evening'); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + """ + ) + cursor.execute("ALTER TYPE work_time ADD VALUE IF NOT EXISTS 'afternoon';") + cursor.execute( + """ + DO $$ + BEGIN + CREATE TYPE day_of_week AS ENUM ( + 'sunday', 'monday', 'tuesday', 'wednesday', + 'thursday', 'friday', 'saturday' + ); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + """ + ) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS companies ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + date_created TIMESTAMP NOT NULL DEFAULT NOW(), + date_updated TIMESTAMP NOT NULL DEFAULT NOW() + ); + """ + ) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + last_name TEXT NOT NULL, + preference work_time NOT NULL, + date_created TIMESTAMP NOT NULL DEFAULT NOW(), + date_updated TIMESTAMP NOT NULL DEFAULT NOW() + ); + """ + ) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS company_users ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE CASCADE, + date_created TIMESTAMP NOT NULL DEFAULT NOW(), + date_updated TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE (user_id, company_id) + ); + """ + ) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS schedules ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + enter_time TIMESTAMP NOT NULL, + day_of_week day_of_week NOT NULL, + out_time TIMESTAMP, + date_created TIMESTAMP NOT NULL DEFAULT NOW(), + date_updated TIMESTAMP NOT NULL DEFAULT NOW() + ); + """ + ) + cursor.execute("ALTER TABLE schedules ALTER COLUMN out_time DROP NOT NULL;") + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS weekly_preferences ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + day_of_week day_of_week NOT NULL, + first work_time NOT NULL, + second work_time NOT NULL, + third work_time NOT NULL, + date_created TIMESTAMP NOT NULL DEFAULT NOW(), + date_updated TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE (user_id, day_of_week) + ); + """ + ) + connection.commit() diff --git a/src/scheduler/database/employee_repository.py b/src/scheduler/database/employee_repository.py new file mode 100644 index 0000000..3096039 --- /dev/null +++ b/src/scheduler/database/employee_repository.py @@ -0,0 +1,167 @@ +class EmployeeRepository: + def __init__(self, connection): + self.connection = connection + + def list(self): + with self.connection.cursor() as cursor: + cursor.execute( + """ + SELECT users.id, company_users.company_id, users.name, users.last_name, users.preference + FROM users + INNER JOIN company_users ON company_users.user_id = users.id + ORDER BY users.id + """ + ) + return cursor.fetchall() + + def create(self, company_id: int, name: str, last_name: str, preference: str): + with self.connection.cursor() as cursor: + cursor.execute( + """ + INSERT INTO users (name, last_name, preference) + VALUES (%s, %s, %s) + RETURNING id, name, last_name, preference + """, + (name, last_name, preference), + ) + user = cursor.fetchone() + cursor.execute( + """ + INSERT INTO company_users (user_id, company_id) + VALUES (%s, %s) + RETURNING company_id + """, + (user["id"], company_id), + ) + company_user = cursor.fetchone() + self.connection.commit() + return {**user, "company_id": company_user["company_id"]} + + def get(self, employee_id: int): + with self.connection.cursor() as cursor: + cursor.execute( + """ + SELECT users.id, company_users.company_id, users.name, users.last_name, users.preference + FROM users + INNER JOIN company_users ON company_users.user_id = users.id + WHERE users.id = %s + """, + (employee_id,), + ) + return cursor.fetchone() + + def delete(self, employee_id: int): + employee = self.get(employee_id) + if employee is None: + return None + with self.connection.cursor() as cursor: + cursor.execute("DELETE FROM users WHERE id = %s", (employee_id,)) + self.connection.commit() + return employee + + def update_preference(self, employee_id: int, preference: str): + with self.connection.cursor() as cursor: + cursor.execute( + """ + UPDATE users + SET preference = %s, date_updated = NOW() + WHERE id = %s + """, + (preference, employee_id), + ) + self.connection.commit() + return self.get(employee_id) + + def update(self, employee_id: int, name: str | None, last_name: str | None, preference: str | None): + updates: list[str] = [] + params: list[object] = [] + if name is not None: + updates.append("name = %s") + params.append(name) + if last_name is not None: + updates.append("last_name = %s") + params.append(last_name) + if preference is not None: + updates.append("preference = %s") + params.append(preference) + if updates: + updates.append("date_updated = NOW()") + params.append(employee_id) + with self.connection.cursor() as cursor: + cursor.execute( + f""" + UPDATE users + SET {", ".join(updates)} + WHERE id = %s + """, + tuple(params), + ) + self.connection.commit() + return self.get(employee_id) + + def update_company(self, employee_id: int, company_id: int): + with self.connection.cursor() as cursor: + cursor.execute("DELETE FROM company_users WHERE user_id = %s", (employee_id,)) + cursor.execute( + """ + INSERT INTO company_users (user_id, company_id) + VALUES (%s, %s) + ON CONFLICT (user_id, company_id) DO UPDATE SET date_updated = NOW() + """, + (employee_id, company_id), + ) + self.connection.commit() + return self.get(employee_id) + + def assign_to_company(self, company_id: int, employee_id: int): + with self.connection.cursor() as cursor: + cursor.execute( + """ + INSERT INTO company_users (user_id, company_id) + VALUES (%s, %s) + ON CONFLICT (user_id, company_id) DO UPDATE SET date_updated = NOW() + RETURNING id, user_id, company_id, date_created, date_updated + """, + (employee_id, company_id), + ) + company_user = cursor.fetchone() + self.connection.commit() + return company_user + + def get_names_by_company(self, company_id: int): + with self.connection.cursor() as cursor: + cursor.execute( + """ + SELECT users.name, users.last_name + FROM users + INNER JOIN company_users ON company_users.user_id = users.id + WHERE company_users.company_id = %s + ORDER BY users.name, users.last_name + """, + (company_id,), + ) + return [f"{row['name']} {row['last_name']}" for row in cursor.fetchall()] + + def save_weekly_preferences(self, employee_id: int, preferences: list): + with self.connection.cursor() as cursor: + for preference in preferences: + cursor.execute( + """ + INSERT INTO weekly_preferences (user_id, day_of_week, first, second, third) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (user_id, day_of_week) DO UPDATE SET + first = EXCLUDED.first, + second = EXCLUDED.second, + third = EXCLUDED.third, + date_updated = NOW() + """, + ( + employee_id, + preference.day_of_week, + preference.first, + preference.second, + preference.third, + ), + ) + self.connection.commit() + return self.get(employee_id) diff --git a/src/scheduler/database/schedule_repository.py b/src/scheduler/database/schedule_repository.py new file mode 100644 index 0000000..56324cf --- /dev/null +++ b/src/scheduler/database/schedule_repository.py @@ -0,0 +1,61 @@ +from datetime import datetime + + +class ScheduleRepository: + def __init__(self, connection): + self.connection = connection + + def create(self, user_id: int, enter_time: datetime, day_of_week: str, out_time: datetime | None): + with self.connection.cursor() as cursor: + cursor.execute( + """ + INSERT INTO schedules (user_id, enter_time, day_of_week, out_time) + VALUES (%s, %s, %s, %s) + RETURNING id, user_id, enter_time, day_of_week, out_time + """, + (user_id, enter_time, day_of_week, out_time), + ) + schedule = cursor.fetchone() + self.connection.commit() + return schedule + + def list_weekly(self, company_id: int | None = None): + params = () + where = "" + if company_id is not None: + where = "WHERE company_users.company_id = %s" + params = (company_id,) + with self.connection.cursor() as cursor: + cursor.execute( + f""" + SELECT schedules.id, schedules.user_id, schedules.enter_time, + schedules.day_of_week, schedules.out_time + FROM schedules + INNER JOIN company_users ON company_users.user_id = schedules.user_id + {where} + ORDER BY schedules.day_of_week, schedules.enter_time, schedules.id + """, + params, + ) + return cursor.fetchall() + + def update_clock_out(self, user_id: int, day_of_week: str, out_time: datetime): + with self.connection.cursor() as cursor: + cursor.execute( + """ + UPDATE schedules + SET out_time = %s, date_updated = NOW() + WHERE id = ( + SELECT id + FROM schedules + WHERE user_id = %s AND day_of_week = %s + ORDER BY date_created DESC + LIMIT 1 + ) + RETURNING id, user_id, enter_time, day_of_week, out_time + """, + (out_time, user_id, day_of_week), + ) + schedule = cursor.fetchone() + self.connection.commit() + return schedule diff --git a/src/scheduler/database/storage.py b/src/scheduler/database/storage.py index e69de29..378dc38 100644 --- a/src/scheduler/database/storage.py +++ b/src/scheduler/database/storage.py @@ -0,0 +1,93 @@ +from scheduler.database.company_repository import CompanyRepository +from scheduler.database.database import Database +from scheduler.database.employee_repository import EmployeeRepository +from scheduler.database.schedule_repository import ScheduleRepository + + +def get_connection(): + return Database().get_connection() + + +def initialize_schema(connection=None): + Database().initialize_schema() + + +def add_company(connection, name): + return CompanyRepository(connection).create(name) + + +def get_company_by_name(connection, name): + return CompanyRepository(connection).get_by_name(name) + + +def remove_company(connection, company_id): + return CompanyRepository(connection).delete(company_id) + + +def get_employee_names_by_company(connection, company_id): + return EmployeeRepository(connection).get_names_by_company(company_id) + + +def add_user(connection, name, last_name, preference): + with connection.cursor() as cursor: + cursor.execute( + """ + INSERT INTO users (name, last_name, preference) + VALUES (%s, %s, %s) + RETURNING id, name, last_name, preference, date_created, date_updated + """, + (name, last_name, preference), + ) + user = cursor.fetchone() + connection.commit() + return user + + +def update_user_preference(connection, user_id, preference): + return EmployeeRepository(connection).update_preference(user_id, preference) + + +def add_company_user(connection, company_id, user_id): + return EmployeeRepository(connection).assign_to_company(company_id, user_id) + + +def remove_user(connection, user_id): + return EmployeeRepository(connection).delete(user_id) + + +def add_schedule(connection, user_id, enter_time, day_of_week, out_time): + return ScheduleRepository(connection).create(user_id, enter_time, day_of_week, out_time) + + +def get_days_worked_by_user(connection, user_id): + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT DISTINCT day_of_week + FROM schedules + WHERE user_id = %s + ORDER BY day_of_week + """, + (user_id,), + ) + return [row["day_of_week"] for row in cursor.fetchall()] + + +def get_user_enter_time_by_day(connection, user_id, day_of_week): + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT enter_time + FROM schedules + WHERE user_id = %s AND day_of_week = %s + ORDER BY date_created DESC + LIMIT 1 + """, + (user_id, day_of_week), + ) + row = cursor.fetchone() + return None if row is None else row["enter_time"] + + +def update_day_end_by_user_and_day(connection, user_id, day_of_week, out_time): + return ScheduleRepository(connection).update_clock_out(user_id, day_of_week, out_time) diff --git a/src/scheduler/main.py b/src/scheduler/main.py new file mode 100644 index 0000000..7ee44dd --- /dev/null +++ b/src/scheduler/main.py @@ -0,0 +1,3 @@ +from scheduler.app import app, create_app + +__all__ = ["app", "create_app"] diff --git a/src/scheduler/manager/__init__.py b/src/scheduler/manager/__init__.py index e69de29..8fc5368 100644 --- a/src/scheduler/manager/__init__.py +++ b/src/scheduler/manager/__init__.py @@ -0,0 +1,3 @@ +from . import conflict, scheduler, validator + +__all__ = ["conflict", "scheduler", "validator"] diff --git a/src/scheduler/manager/conflict.py b/src/scheduler/manager/conflict.py index e69de29..d3d02d1 100644 --- a/src/scheduler/manager/conflict.py +++ b/src/scheduler/manager/conflict.py @@ -0,0 +1,32 @@ +# Conflict resolution guide. +# Import validator functions before moving employees between shifts or days. + + +def detect_preferred_shift_conflict(connection, user_id, day_of_week, preferred_shift): + # check if preferred shift is already full for selected day + # check if user already works that day + # check if user already worked five days this week + pass + + +def resolve_conflict_same_day(connection, user_id, day_of_week): + # loop over other shifts on the same day + # choose the first shift where user can legally work + # assign only after validator confirms the schedule is valid + pass + + +def resolve_conflict_next_day(connection, user_id, current_day): + # if same-day alternatives fail, move to next day + # loop through next-day shifts by preference priority + # assign only after validator confirms the schedule is valid + pass + + +def resolve_missing_second_employee(connection, company_id, day_of_week, shift): + # find shifts with only one employee assigned + # loop over available employees in company + # skip employees who already work that day + # skip employees who already worked five days this week + # assign a valid extra employee to satisfy minimum coverage + pass diff --git a/src/scheduler/manager/scheduler.py b/src/scheduler/manager/scheduler.py index e69de29..77168e0 100644 --- a/src/scheduler/manager/scheduler.py +++ b/src/scheduler/manager/scheduler.py @@ -0,0 +1,52 @@ +# Schedule generation guide. +# Import database.storage for queries and manager.validator/conflict for rules. + + +def generate_weekly_schedule(connection, company_id): + # load all employees for selected company + # load employee preferences for all seven days + # loop through each day of the week + # loop through morning, afternoon, and evening shifts + # assign employees based on preferences first + # validate one-shift-per-day before each assignment + # validate five-days-per-week before each assignment + # ensure at least two employees per shift after assignments + # resolve conflicts by assigning another shift on same day or next day + # save final schedule rows to the schedules table + pass + + +def assign_preferred_shift(connection, user_id, day_of_week, preferred_shift): + # validate employee can join selected shift + # call storage.add_schedule when assignment is valid + # return validation error if assignment is not valid + pass + + +def fill_missing_shift_coverage(connection, company_id, day_of_week, shift): + # find company shift with fewer than two employees + # if only one employee is assigned, search for another valid employee + # randomly choose or rotate through employees who have not worked five days + # add the selected employee to the schedule + pass + + +def register_clock_in(connection, user_id, day_of_week, enter_time): + # reject if user already worked the same day + # reject if user already worked five days this week + # save enter_time through storage.add_schedule or update an open schedule row + pass + + +def register_clock_out(connection, user_id, day_of_week, out_time): + # call storage.update_day_end_by_user_and_day + # keep the existing schedule row and update out_time only + pass + + +def print_final_schedule(connection, company_id): + # query final schedule with employee names + # group output by day and shift + # print employees assigned to each shift + # print warnings for shifts with fewer than two employees + pass diff --git a/src/scheduler/manager/validator.py b/src/scheduler/manager/validator.py index e69de29..905cf85 100644 --- a/src/scheduler/manager/validator.py +++ b/src/scheduler/manager/validator.py @@ -0,0 +1,38 @@ +# Validation guide for schedule rules. +# Import scheduler.database.storage query functions before implementing these checks. + + +def validate_user_has_not_worked_same_day(connection, user_id, day_of_week): + # query schedules for user_id and day_of_week + # if a schedule exists, reject the new assignment + # otherwise allow scheduler to continue + pass + + +def validate_user_has_not_worked_more_than_five_days(connection, user_id): + # call storage.get_days_worked_by_user(connection, user_id) + # count distinct worked days with a loop + # reject if count is five or more + # otherwise allow assignment + pass + + +def validate_shift_has_minimum_two_employees(connection, company_id, day_of_week, shift): + # query schedules for company_id, day_of_week, and shift + # count assigned employees + # if fewer than two, scheduler must add another available employee + pass + + +def validate_employee_can_join_shift(connection, user_id, day_of_week, shift): + # check one-shift-per-day rule first + # check five-days-per-week rule second + # check shift coverage and capacity rules last + pass + + +def validate_company_has_enough_employees(connection, company_id): + # get employee names by company + # reject schedule generation if company has fewer than two employees + # otherwise continue schedule generation + pass diff --git a/src/scheduler/menu.py b/src/scheduler/menu.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/scheduler/models/__init__.py b/src/scheduler/models/__init__.py index e69de29..330ed59 100644 --- a/src/scheduler/models/__init__.py +++ b/src/scheduler/models/__init__.py @@ -0,0 +1,3 @@ +from . import employee, schedule, shift + +__all__ = ["employee", "schedule", "shift"] diff --git a/src/scheduler/models/employee.py b/src/scheduler/models/employee.py index e69de29..f4a9574 100644 --- a/src/scheduler/models/employee.py +++ b/src/scheduler/models/employee.py @@ -0,0 +1,27 @@ +from scheduler.database import storage + + +def define_employee_fields(): + # include id from users table + # include company_id from company_users table + # include name and last_name from users table + # include preferred shift for each day of the week + # include optional priority ranking for bonus preference support + pass + + +def register_employee_preference(connection, user_id, preference): + return storage.update_user_preference(connection, user_id, preference) + + +def get_employee_names_by_company(connection, company_name): + company = storage.get_company_by_name(connection, company_name) + if company is None: + return None + return storage.get_employee_names_by_company(connection, company["id"]) + + +def register_user_into_company(connection, company_id, name, last_name, preference): + user = storage.add_user(connection, name, last_name, preference) + storage.add_company_user(connection, company_id, user["id"]) + return user diff --git a/src/scheduler/models/schedule.py b/src/scheduler/models/schedule.py index e69de29..882520e 100644 --- a/src/scheduler/models/schedule.py +++ b/src/scheduler/models/schedule.py @@ -0,0 +1,23 @@ +from scheduler.database import storage + + +def define_schedule_fields(): + # include user_id to identify the employee + # include company_id if schedules need to be filtered by company + # include day_of_week for the assigned day + # include shift value for morning, afternoon, or evening + # include enter_time and out_time for clock-in and clock-out + pass + + +def register_user_enter_and_out(connection, user_id, enter_time, day_of_week, out_time): + # Add manager validation before saving once scheduling rules are complete. + return storage.add_schedule(connection, user_id, enter_time, day_of_week, out_time) + + +def output_weekly_schedule(): + # group saved schedules by day of week + # inside each day, group schedules by shift + # list employee names for each shift + # show missing coverage when a shift has fewer than two employees + pass diff --git a/src/scheduler/models/shift.py b/src/scheduler/models/shift.py index e69de29..f75f536 100644 --- a/src/scheduler/models/shift.py +++ b/src/scheduler/models/shift.py @@ -0,0 +1,22 @@ +# Shift model guide. +# Keep shift names consistent with the PostgreSQL work_time enum. + + +def define_shift_values(): + # morning maps to work_time value "morning" + # afternoon can map to "evening" if you keep the Rust enum name + # evening maps to work_time value "evening" + pass + + +def define_day_values(): + # use sunday through saturday to match the day_of_week enum + # loop over all seven days when generating the weekly schedule + pass + + +def compare_shift_capacity(): + # count employees assigned to one shift on one day + # if the count is fewer than two, scheduler must add another employee + # if the preferred shift is full, conflict manager must choose another shift + pass diff --git a/src/scheduler/tui/screens/employees.py b/src/scheduler/tui/screens/employees.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/scheduler/tui/screens/menu.py b/src/scheduler/tui/screens/menu.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/scheduler/tui/screens/preferences.py b/src/scheduler/tui/screens/preferences.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/scheduler/tui/screens/schedule.py b/src/scheduler/tui/screens/schedule.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/scheduler/tui/styles/scheduler.tcss b/src/scheduler/tui/styles/scheduler.tcss deleted file mode 100644 index e69de29..0000000