schedule-module 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- schedule_module-0.1.0.dist-info/METADATA +80 -0
- schedule_module-0.1.0.dist-info/RECORD +13 -0
- schedule_module-0.1.0.dist-info/WHEEL +4 -0
- schedule_module-0.1.0.dist-info/entry_points.txt +2 -0
- src/__init__.py +19 -0
- src/cli.py +109 -0
- src/core/models.py +71 -0
- src/db/connection.py +17 -0
- src/repositories/category_repository.py +35 -0
- src/repositories/date_register_repository.py +49 -0
- src/repositories/schedule_repository.py +36 -0
- src/services/schedule_service.py +56 -0
- src/utils/date_utils.py +12 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: schedule-module
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A reusable scheduling capability module
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Requires-Dist: asyncpg>=0.28.0
|
|
7
|
+
Requires-Dist: pydantic>=2.0.0
|
|
8
|
+
Requires-Dist: python-dateutil>=2.8.2
|
|
9
|
+
Requires-Dist: pytz>=2023.3
|
|
10
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.21.1; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=7.4.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: testcontainers[postgres]>=3.7.1; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Schedule Module
|
|
18
|
+
|
|
19
|
+
A reusable, production-grade scheduling capability module designed for both human developers and AI agents.
|
|
20
|
+
It provides a robust scheduling system that allows projects to create, manage, and query events, routines, and time-based records across systems using a strictly structured PostgreSQL database.
|
|
21
|
+
|
|
22
|
+
## Architecture & Principles
|
|
23
|
+
|
|
24
|
+
- **Installability**: Can be initialized easily via `make initial`.
|
|
25
|
+
- **Backward Compatibility**: Migrations are versioned and immutable.
|
|
26
|
+
- **Agent-First Design**: Includes MCP integration and YAML action definitions for native AI consumption.
|
|
27
|
+
- **Human Usability**: Clean API, strongly typed Pydantic models.
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
### 1. Installation
|
|
32
|
+
|
|
33
|
+
To initialize the database locally via Docker Compose, simply run:
|
|
34
|
+
```bash
|
|
35
|
+
make initial
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
This will:
|
|
39
|
+
1. Spin up a PostgreSQL container using `docker-compose.yml`.
|
|
40
|
+
2. Apply the initial schema and tables (`/db/ddl`).
|
|
41
|
+
3. Run all pending migrations (`/db/migrations`).
|
|
42
|
+
|
|
43
|
+
### 2. Python API
|
|
44
|
+
|
|
45
|
+
Use the `Schedule` service to interact with your data:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from datetime import datetime, timezone
|
|
49
|
+
import asyncio
|
|
50
|
+
from uuid import uuid4
|
|
51
|
+
from src import Schedule, ScheduledRoutineCreate, RecurrencyType
|
|
52
|
+
|
|
53
|
+
async def main():
|
|
54
|
+
async with Schedule() as schedule_service:
|
|
55
|
+
routine = ScheduledRoutineCreate(
|
|
56
|
+
name="Daily Standup",
|
|
57
|
+
start_date_time=datetime.now(timezone.utc),
|
|
58
|
+
end_date_time=datetime.now(timezone.utc),
|
|
59
|
+
recurrency=RecurrencyType.daily
|
|
60
|
+
)
|
|
61
|
+
created_routine = await schedule_service.add_schedule(routine)
|
|
62
|
+
print("Created Routine ID:", created_routine.id)
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
asyncio.run(main())
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 3. Agent Integration
|
|
69
|
+
|
|
70
|
+
Refer to `agent/mcp.yaml` and `agent/actions.yaml` for using this module as a tool in AI workflows via MCP (Model Context Protocol).
|
|
71
|
+
|
|
72
|
+
## Infrastructure
|
|
73
|
+
|
|
74
|
+
We include boilerplate configurations for deployments:
|
|
75
|
+
- `infra/docker-compose.yml`: For local deployments
|
|
76
|
+
- `infra/terraform`: Example Terraform modules for AWS RDS Postgres deployments
|
|
77
|
+
|
|
78
|
+
## Publishing
|
|
79
|
+
|
|
80
|
+
The module includes a `.github/workflows/publish.yml` that automatically publishes to npm and PyPI whenever changes are merged into the `main` branch.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
src/__init__.py,sha256=QaiQhviZkGg-UmK0q983LvQqSK29465_YbDSZTDx9rc,435
|
|
2
|
+
src/cli.py,sha256=YYTanku6vtKLFIfAzNidbwgdexXaCC4gmEoiq4ZwoTs,3558
|
|
3
|
+
src/core/models.py,sha256=kX1TF9PLuPVPzBO8FfWVveu7RwH3QgTHMrjJEVbOzF8,1847
|
|
4
|
+
src/db/connection.py,sha256=PROYZ8DNOjtZK4Hnph004dz_FvyG6TWbG_dBf-5TlKQ,531
|
|
5
|
+
src/repositories/category_repository.py,sha256=yf3VeEtz4XhCK_RpuH4qbwTJmnZt452Jb7j0FQ_LSpo,1325
|
|
6
|
+
src/repositories/date_register_repository.py,sha256=4aZHTQMYcaDqV1SKw5Azn0yF6pnETIEgjaVraPz1JrQ,2558
|
|
7
|
+
src/repositories/schedule_repository.py,sha256=kVZWdQyBnXlXLSHJpHaORHsrHTVRdtyoDF1QRyRGuW8,1561
|
|
8
|
+
src/services/schedule_service.py,sha256=orNpYotng0wpmNOR9Rf_n_Cxcq1M-vxCk4JWIdNcxcg,2297
|
|
9
|
+
src/utils/date_utils.py,sha256=NxniUVhMMvqUMDcqRrOA6CRDvD1PM6vSpevbgJ3apas,412
|
|
10
|
+
schedule_module-0.1.0.dist-info/METADATA,sha256=t79b60rb967yLsyEFgWGftxSFTOKtX7kmfo3LA3G7nE,2739
|
|
11
|
+
schedule_module-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
12
|
+
schedule_module-0.1.0.dist-info/entry_points.txt,sha256=5WpYPWq6sopoTteFpk_MOz9SS5BnFfPoUdpJ6jBXNWc,53
|
|
13
|
+
schedule_module-0.1.0.dist-info/RECORD,,
|
src/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from .services.schedule_service import Schedule
|
|
2
|
+
from .core.models import (
|
|
3
|
+
RecurrencyType, RegisterType,
|
|
4
|
+
Category, CategoryCreate,
|
|
5
|
+
ScheduledRoutine, ScheduledRoutineCreate,
|
|
6
|
+
DateRegister, DateRegisterCreate
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Schedule",
|
|
11
|
+
"RecurrencyType",
|
|
12
|
+
"RegisterType",
|
|
13
|
+
"Category",
|
|
14
|
+
"CategoryCreate",
|
|
15
|
+
"ScheduledRoutine",
|
|
16
|
+
"ScheduledRoutineCreate",
|
|
17
|
+
"DateRegister",
|
|
18
|
+
"DateRegisterCreate"
|
|
19
|
+
]
|
src/cli.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import asyncpg
|
|
6
|
+
from src.db.connection import DATABASE_URL
|
|
7
|
+
|
|
8
|
+
ASYNC_PG_URL = DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")
|
|
9
|
+
|
|
10
|
+
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
11
|
+
DB_DIR = os.path.join(BASE_DIR, "db")
|
|
12
|
+
|
|
13
|
+
async def run_sql_file(conn, filepath):
|
|
14
|
+
with open(filepath, "r") as f:
|
|
15
|
+
sql = f.read()
|
|
16
|
+
if sql.strip():
|
|
17
|
+
await conn.execute(sql)
|
|
18
|
+
|
|
19
|
+
async def init_schema_version(conn):
|
|
20
|
+
# Ensure the table exists
|
|
21
|
+
await conn.execute("""
|
|
22
|
+
CREATE SCHEMA IF NOT EXISTS schedule_module;
|
|
23
|
+
CREATE TABLE IF NOT EXISTS schedule_module.schema_version (
|
|
24
|
+
version INT PRIMARY KEY,
|
|
25
|
+
name VARCHAR(255) NOT NULL,
|
|
26
|
+
executed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
27
|
+
);
|
|
28
|
+
""")
|
|
29
|
+
|
|
30
|
+
async def get_applied_migrations(conn):
|
|
31
|
+
rows = await conn.fetch("SELECT version FROM schedule_module.schema_version")
|
|
32
|
+
return {row['version'] for row in rows}
|
|
33
|
+
|
|
34
|
+
async def apply_migrations(conn):
|
|
35
|
+
await init_schema_version(conn)
|
|
36
|
+
applied = await get_applied_migrations(conn)
|
|
37
|
+
|
|
38
|
+
migrations_dir = os.path.join(DB_DIR, "migrations")
|
|
39
|
+
files = sorted(os.listdir(migrations_dir))
|
|
40
|
+
|
|
41
|
+
for filename in files:
|
|
42
|
+
if not filename.endswith(".sql"):
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
match = re.match(r"^(\d+)_", filename)
|
|
46
|
+
if match:
|
|
47
|
+
version = int(match.group(1))
|
|
48
|
+
if version not in applied:
|
|
49
|
+
print(f"Applying migration: {filename}")
|
|
50
|
+
filepath = os.path.join(migrations_dir, filename)
|
|
51
|
+
await run_sql_file(conn, filepath)
|
|
52
|
+
await conn.execute(
|
|
53
|
+
"INSERT INTO schedule_module.schema_version (version, name) VALUES ($1, $2)",
|
|
54
|
+
version, filename
|
|
55
|
+
)
|
|
56
|
+
else:
|
|
57
|
+
print(f"Skipping incorrectly formatted migration file: {filename}")
|
|
58
|
+
|
|
59
|
+
async def bootstrap():
|
|
60
|
+
print("Running bootstrap...")
|
|
61
|
+
conn = await asyncpg.connect(ASYNC_PG_URL)
|
|
62
|
+
try:
|
|
63
|
+
# Run DDLs
|
|
64
|
+
ddl_dir = os.path.join(DB_DIR, "ddl")
|
|
65
|
+
for filename in sorted(os.listdir(ddl_dir)):
|
|
66
|
+
if filename.endswith(".sql") and filename != "schema.sql":
|
|
67
|
+
print(f"Applying DDL: {filename}")
|
|
68
|
+
await run_sql_file(conn, os.path.join(ddl_dir, filename))
|
|
69
|
+
|
|
70
|
+
# Apply Migrations
|
|
71
|
+
await apply_migrations(conn)
|
|
72
|
+
finally:
|
|
73
|
+
await conn.close()
|
|
74
|
+
print("Bootstrap complete.")
|
|
75
|
+
|
|
76
|
+
async def seed():
|
|
77
|
+
print("Running seed...")
|
|
78
|
+
conn = await asyncpg.connect(ASYNC_PG_URL)
|
|
79
|
+
try:
|
|
80
|
+
seed_path = os.path.join(DB_DIR, "bootstrap", "seed.sql")
|
|
81
|
+
if os.path.exists(seed_path):
|
|
82
|
+
await run_sql_file(conn, seed_path)
|
|
83
|
+
print("Seed complete.")
|
|
84
|
+
else:
|
|
85
|
+
print("No seed.sql found.")
|
|
86
|
+
finally:
|
|
87
|
+
await conn.close()
|
|
88
|
+
|
|
89
|
+
def main():
|
|
90
|
+
parser = argparse.ArgumentParser(description="Schedule Module CLI")
|
|
91
|
+
parser.add_argument("command", choices=["bootstrap", "migrate", "seed"], help="Command to execute")
|
|
92
|
+
|
|
93
|
+
args = parser.parse_args()
|
|
94
|
+
|
|
95
|
+
if args.command == "bootstrap":
|
|
96
|
+
asyncio.run(bootstrap())
|
|
97
|
+
elif args.command == "migrate":
|
|
98
|
+
async def _migrate():
|
|
99
|
+
conn = await asyncpg.connect(ASYNC_PG_URL)
|
|
100
|
+
try:
|
|
101
|
+
await apply_migrations(conn)
|
|
102
|
+
finally:
|
|
103
|
+
await conn.close()
|
|
104
|
+
asyncio.run(_migrate())
|
|
105
|
+
elif args.command == "seed":
|
|
106
|
+
asyncio.run(seed())
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
main()
|
src/core/models.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
class RecurrencyType(str, Enum):
|
|
8
|
+
daily = 'daily'
|
|
9
|
+
weekly = 'weekly'
|
|
10
|
+
biweekly = 'biweekly'
|
|
11
|
+
monthly = 'monthly'
|
|
12
|
+
custom_days = 'custom_days'
|
|
13
|
+
|
|
14
|
+
class RegisterType(str, Enum):
|
|
15
|
+
event = 'event'
|
|
16
|
+
schedule = 'schedule'
|
|
17
|
+
|
|
18
|
+
class CategoryBase(BaseModel):
|
|
19
|
+
name: str
|
|
20
|
+
description: Optional[str] = None
|
|
21
|
+
color: Optional[str] = None
|
|
22
|
+
icon: Optional[str] = None
|
|
23
|
+
image: Optional[str] = None
|
|
24
|
+
note: Optional[str] = None
|
|
25
|
+
|
|
26
|
+
class CategoryCreate(CategoryBase):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
class Category(CategoryBase):
|
|
30
|
+
id: UUID
|
|
31
|
+
creation_date_time: datetime
|
|
32
|
+
update_date_time: datetime
|
|
33
|
+
model_config = ConfigDict(from_attributes=True)
|
|
34
|
+
|
|
35
|
+
class ScheduledRoutineBase(BaseModel):
|
|
36
|
+
name: str
|
|
37
|
+
category_id: Optional[UUID] = None
|
|
38
|
+
start_date_time: datetime
|
|
39
|
+
end_date_time: datetime
|
|
40
|
+
description: Optional[str] = None
|
|
41
|
+
note: Optional[str] = None
|
|
42
|
+
recurrency: RecurrencyType
|
|
43
|
+
|
|
44
|
+
class ScheduledRoutineCreate(ScheduledRoutineBase):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
class ScheduledRoutine(ScheduledRoutineBase):
|
|
48
|
+
id: UUID
|
|
49
|
+
creation_date_time: datetime
|
|
50
|
+
update_date_time: datetime
|
|
51
|
+
model_config = ConfigDict(from_attributes=True)
|
|
52
|
+
|
|
53
|
+
class DateRegisterBase(BaseModel):
|
|
54
|
+
type: RegisterType
|
|
55
|
+
name: str
|
|
56
|
+
start_date_time: datetime
|
|
57
|
+
end_date_time: datetime
|
|
58
|
+
description: Optional[str] = None
|
|
59
|
+
note: Optional[str] = None
|
|
60
|
+
category_id: Optional[UUID] = None
|
|
61
|
+
scheduled_routine_id: Optional[UUID] = None
|
|
62
|
+
relationship_field: Optional[str] = None
|
|
63
|
+
|
|
64
|
+
class DateRegisterCreate(DateRegisterBase):
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
class DateRegister(DateRegisterBase):
|
|
68
|
+
id: UUID
|
|
69
|
+
creation_date_time: datetime
|
|
70
|
+
update_date_time: datetime
|
|
71
|
+
model_config = ConfigDict(from_attributes=True)
|
src/db/connection.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
3
|
+
|
|
4
|
+
# Database settings
|
|
5
|
+
DB_HOST = os.getenv("DB_HOST", "localhost")
|
|
6
|
+
DB_PORT = os.getenv("DB_PORT", "5432")
|
|
7
|
+
DB_USER = os.getenv("DB_USER", "postgres")
|
|
8
|
+
DB_PASS = os.getenv("DB_PASS", "postgres")
|
|
9
|
+
DB_NAME = os.getenv("DB_NAME", "postgres")
|
|
10
|
+
|
|
11
|
+
DATABASE_URL = f"postgresql+asyncpg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
|
12
|
+
|
|
13
|
+
engine = create_async_engine(DATABASE_URL, echo=False)
|
|
14
|
+
|
|
15
|
+
async def get_connection():
|
|
16
|
+
async with engine.begin() as conn:
|
|
17
|
+
yield conn
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from uuid import UUID
|
|
2
|
+
from sqlalchemy import text
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncConnection
|
|
4
|
+
from typing import Optional, List
|
|
5
|
+
from src.core.models import CategoryCreate, Category
|
|
6
|
+
|
|
7
|
+
class CategoryRepository:
|
|
8
|
+
def __init__(self, conn: AsyncConnection):
|
|
9
|
+
self.conn = conn
|
|
10
|
+
|
|
11
|
+
async def create(self, category: CategoryCreate) -> Category:
|
|
12
|
+
query = text("""
|
|
13
|
+
INSERT INTO schedule_module.category
|
|
14
|
+
(name, description, color, icon, image, note)
|
|
15
|
+
VALUES (:name, :description, :color, :icon, :image, :note)
|
|
16
|
+
RETURNING *
|
|
17
|
+
""")
|
|
18
|
+
result = await self.conn.execute(query, {
|
|
19
|
+
"name": category.name,
|
|
20
|
+
"description": category.description,
|
|
21
|
+
"color": category.color,
|
|
22
|
+
"icon": category.icon,
|
|
23
|
+
"image": category.image,
|
|
24
|
+
"note": category.note
|
|
25
|
+
})
|
|
26
|
+
row = result.fetchone()
|
|
27
|
+
return Category.model_validate(dict(row._mapping))
|
|
28
|
+
|
|
29
|
+
async def get_by_id(self, category_id: UUID) -> Optional[Category]:
|
|
30
|
+
query = text("SELECT * FROM schedule_module.category WHERE id = :id")
|
|
31
|
+
result = await self.conn.execute(query, {"id": category_id})
|
|
32
|
+
row = result.fetchone()
|
|
33
|
+
if row:
|
|
34
|
+
return Category.model_validate(dict(row._mapping))
|
|
35
|
+
return None
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from uuid import UUID
|
|
2
|
+
from sqlalchemy import text
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncConnection
|
|
4
|
+
from typing import Optional, List
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from src.core.models import DateRegisterCreate, DateRegister
|
|
7
|
+
|
|
8
|
+
class DateRegisterRepository:
|
|
9
|
+
def __init__(self, conn: AsyncConnection):
|
|
10
|
+
self.conn = conn
|
|
11
|
+
|
|
12
|
+
async def create(self, register: DateRegisterCreate) -> DateRegister:
|
|
13
|
+
query = text("""
|
|
14
|
+
INSERT INTO schedule_module.date_register
|
|
15
|
+
(type, name, start_date_time, end_date_time, description, note, category_id, scheduled_routine_id, relationship_field)
|
|
16
|
+
VALUES (:type, :name, :start_date_time, :end_date_time, :description, :note, :category_id, :scheduled_routine_id, :relationship_field)
|
|
17
|
+
RETURNING *
|
|
18
|
+
""")
|
|
19
|
+
result = await self.conn.execute(query, {
|
|
20
|
+
"type": register.type.value,
|
|
21
|
+
"name": register.name,
|
|
22
|
+
"start_date_time": register.start_date_time,
|
|
23
|
+
"end_date_time": register.end_date_time,
|
|
24
|
+
"description": register.description,
|
|
25
|
+
"note": register.note,
|
|
26
|
+
"category_id": register.category_id,
|
|
27
|
+
"scheduled_routine_id": register.scheduled_routine_id,
|
|
28
|
+
"relationship_field": register.relationship_field
|
|
29
|
+
})
|
|
30
|
+
row = result.fetchone()
|
|
31
|
+
return DateRegister.model_validate(dict(row._mapping))
|
|
32
|
+
|
|
33
|
+
async def get_by_relationship(self, relationship_field: str) -> List[DateRegister]:
|
|
34
|
+
query = text("SELECT * FROM schedule_module.date_register WHERE relationship_field = :rel")
|
|
35
|
+
result = await self.conn.execute(query, {"rel": relationship_field})
|
|
36
|
+
return [DateRegister.model_validate(dict(row._mapping)) for row in result.fetchall()]
|
|
37
|
+
|
|
38
|
+
async def get_by_schedule(self, scheduled_routine_id: UUID) -> List[DateRegister]:
|
|
39
|
+
query = text("SELECT * FROM schedule_module.date_register WHERE scheduled_routine_id = :sch")
|
|
40
|
+
result = await self.conn.execute(query, {"sch": scheduled_routine_id})
|
|
41
|
+
return [DateRegister.model_validate(dict(row._mapping)) for row in result.fetchall()]
|
|
42
|
+
|
|
43
|
+
async def get_dates_from_range(self, start: datetime, end: datetime) -> List[DateRegister]:
|
|
44
|
+
query = text("""
|
|
45
|
+
SELECT * FROM schedule_module.date_register
|
|
46
|
+
WHERE start_date_time < :end AND end_date_time > :start
|
|
47
|
+
""")
|
|
48
|
+
result = await self.conn.execute(query, {"start": start, "end": end})
|
|
49
|
+
return [DateRegister.model_validate(dict(row._mapping)) for row in result.fetchall()]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from uuid import UUID
|
|
2
|
+
from sqlalchemy import text
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncConnection
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from src.core.models import ScheduledRoutineCreate, ScheduledRoutine
|
|
6
|
+
|
|
7
|
+
class ScheduleRepository:
|
|
8
|
+
def __init__(self, conn: AsyncConnection):
|
|
9
|
+
self.conn = conn
|
|
10
|
+
|
|
11
|
+
async def create(self, routine: ScheduledRoutineCreate) -> ScheduledRoutine:
|
|
12
|
+
query = text("""
|
|
13
|
+
INSERT INTO schedule_module.scheduled_routine
|
|
14
|
+
(name, category_id, start_date_time, end_date_time, description, note, recurrency)
|
|
15
|
+
VALUES (:name, :category_id, :start_date_time, :end_date_time, :description, :note, :recurrency)
|
|
16
|
+
RETURNING *
|
|
17
|
+
""")
|
|
18
|
+
result = await self.conn.execute(query, {
|
|
19
|
+
"name": routine.name,
|
|
20
|
+
"category_id": routine.category_id,
|
|
21
|
+
"start_date_time": routine.start_date_time,
|
|
22
|
+
"end_date_time": routine.end_date_time,
|
|
23
|
+
"description": routine.description,
|
|
24
|
+
"note": routine.note,
|
|
25
|
+
"recurrency": routine.recurrency.value
|
|
26
|
+
})
|
|
27
|
+
row = result.fetchone()
|
|
28
|
+
return ScheduledRoutine.model_validate(dict(row._mapping))
|
|
29
|
+
|
|
30
|
+
async def get_by_id(self, routine_id: UUID) -> Optional[ScheduledRoutine]:
|
|
31
|
+
query = text("SELECT * FROM schedule_module.scheduled_routine WHERE id = :id")
|
|
32
|
+
result = await self.conn.execute(query, {"id": routine_id})
|
|
33
|
+
row = result.fetchone()
|
|
34
|
+
if row:
|
|
35
|
+
return ScheduledRoutine.model_validate(dict(row._mapping))
|
|
36
|
+
return None
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from uuid import UUID
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncConnection
|
|
5
|
+
|
|
6
|
+
from src.core.models import (
|
|
7
|
+
ScheduledRoutineCreate, ScheduledRoutine,
|
|
8
|
+
DateRegisterCreate, DateRegister, RegisterType,
|
|
9
|
+
CategoryCreate, Category
|
|
10
|
+
)
|
|
11
|
+
from src.repositories.schedule_repository import ScheduleRepository
|
|
12
|
+
from src.repositories.date_register_repository import DateRegisterRepository
|
|
13
|
+
from src.repositories.category_repository import CategoryRepository
|
|
14
|
+
from src.db.connection import engine
|
|
15
|
+
|
|
16
|
+
class Schedule:
|
|
17
|
+
"""Primary service class for the Schedule Module"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, conn: Optional[AsyncConnection] = None):
|
|
20
|
+
self._conn = conn
|
|
21
|
+
|
|
22
|
+
async def __aenter__(self):
|
|
23
|
+
if self._conn is None:
|
|
24
|
+
self._engine_conn = engine.begin()
|
|
25
|
+
self.conn = await self._engine_conn.__aenter__()
|
|
26
|
+
else:
|
|
27
|
+
self.conn = self._conn
|
|
28
|
+
self._init_repos()
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
32
|
+
if self._conn is None:
|
|
33
|
+
await self._engine_conn.__aexit__(exc_type, exc_val, exc_tb)
|
|
34
|
+
|
|
35
|
+
def _init_repos(self):
|
|
36
|
+
self.schedule_repo = ScheduleRepository(self.conn)
|
|
37
|
+
self.date_register_repo = DateRegisterRepository(self.conn)
|
|
38
|
+
self.category_repo = CategoryRepository(self.conn)
|
|
39
|
+
|
|
40
|
+
async def add_category(self, category: CategoryCreate) -> Category:
|
|
41
|
+
return await self.category_repo.create(category)
|
|
42
|
+
|
|
43
|
+
async def add_date_record(self, register: DateRegisterCreate) -> DateRegister:
|
|
44
|
+
return await self.date_register_repo.create(register)
|
|
45
|
+
|
|
46
|
+
async def add_schedule(self, routine: ScheduledRoutineCreate) -> ScheduledRoutine:
|
|
47
|
+
return await self.schedule_repo.create(routine)
|
|
48
|
+
|
|
49
|
+
async def get_records_for_relationship(self, relationship_field: str) -> List[DateRegister]:
|
|
50
|
+
return await self.date_register_repo.get_by_relationship(relationship_field)
|
|
51
|
+
|
|
52
|
+
async def get_records_for_schedule(self, schedule_id: UUID) -> List[DateRegister]:
|
|
53
|
+
return await self.date_register_repo.get_by_schedule(schedule_id)
|
|
54
|
+
|
|
55
|
+
async def get_dates_from_range(self, start: datetime, end: datetime) -> List[DateRegister]:
|
|
56
|
+
return await self.date_register_repo.get_dates_from_range(start, end)
|
src/utils/date_utils.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
import pytz
|
|
3
|
+
|
|
4
|
+
def normalize_to_utc(dt: datetime) -> datetime:
|
|
5
|
+
"""Normalize datetime to UTC."""
|
|
6
|
+
if dt.tzinfo is None:
|
|
7
|
+
return dt.replace(tzinfo=pytz.UTC)
|
|
8
|
+
return dt.astimezone(pytz.UTC)
|
|
9
|
+
|
|
10
|
+
def is_overlap(start1: datetime, end1: datetime, start2: datetime, end2: datetime) -> bool:
|
|
11
|
+
"""Check if two date ranges overlap."""
|
|
12
|
+
return start1 < end2 and start2 < end1
|