schedule-module 0.1.0__tar.gz
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/.gitignore +13 -0
- schedule_module-0.1.0/PKG-INFO +80 -0
- schedule_module-0.1.0/README.md +64 -0
- schedule_module-0.1.0/db/bootstrap/seed.sql +8 -0
- schedule_module-0.1.0/db/ddl/001_init.sql +22 -0
- schedule_module-0.1.0/db/ddl/002_extensions.sql +1 -0
- schedule_module-0.1.0/db/ddl/schema.sql +76 -0
- schedule_module-0.1.0/db/migrations/000_schema_version.sql +5 -0
- schedule_module-0.1.0/db/migrations/001_create_tables.sql +43 -0
- schedule_module-0.1.0/pyproject.toml +30 -0
- schedule_module-0.1.0/src/__init__.py +19 -0
- schedule_module-0.1.0/src/cli.py +109 -0
- schedule_module-0.1.0/src/core/models.py +71 -0
- schedule_module-0.1.0/src/db/connection.py +17 -0
- schedule_module-0.1.0/src/repositories/category_repository.py +35 -0
- schedule_module-0.1.0/src/repositories/date_register_repository.py +49 -0
- schedule_module-0.1.0/src/repositories/schedule_repository.py +36 -0
- schedule_module-0.1.0/src/services/schedule_service.py +56 -0
- schedule_module-0.1.0/src/utils/date_utils.py +12 -0
- schedule_module-0.1.0/tests/integration/test_service.py +10 -0
- schedule_module-0.1.0/tests/unit/test_utils.py +21 -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,64 @@
|
|
|
1
|
+
# Schedule Module
|
|
2
|
+
|
|
3
|
+
A reusable, production-grade scheduling capability module designed for both human developers and AI agents.
|
|
4
|
+
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.
|
|
5
|
+
|
|
6
|
+
## Architecture & Principles
|
|
7
|
+
|
|
8
|
+
- **Installability**: Can be initialized easily via `make initial`.
|
|
9
|
+
- **Backward Compatibility**: Migrations are versioned and immutable.
|
|
10
|
+
- **Agent-First Design**: Includes MCP integration and YAML action definitions for native AI consumption.
|
|
11
|
+
- **Human Usability**: Clean API, strongly typed Pydantic models.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### 1. Installation
|
|
16
|
+
|
|
17
|
+
To initialize the database locally via Docker Compose, simply run:
|
|
18
|
+
```bash
|
|
19
|
+
make initial
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
This will:
|
|
23
|
+
1. Spin up a PostgreSQL container using `docker-compose.yml`.
|
|
24
|
+
2. Apply the initial schema and tables (`/db/ddl`).
|
|
25
|
+
3. Run all pending migrations (`/db/migrations`).
|
|
26
|
+
|
|
27
|
+
### 2. Python API
|
|
28
|
+
|
|
29
|
+
Use the `Schedule` service to interact with your data:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
import asyncio
|
|
34
|
+
from uuid import uuid4
|
|
35
|
+
from src import Schedule, ScheduledRoutineCreate, RecurrencyType
|
|
36
|
+
|
|
37
|
+
async def main():
|
|
38
|
+
async with Schedule() as schedule_service:
|
|
39
|
+
routine = ScheduledRoutineCreate(
|
|
40
|
+
name="Daily Standup",
|
|
41
|
+
start_date_time=datetime.now(timezone.utc),
|
|
42
|
+
end_date_time=datetime.now(timezone.utc),
|
|
43
|
+
recurrency=RecurrencyType.daily
|
|
44
|
+
)
|
|
45
|
+
created_routine = await schedule_service.add_schedule(routine)
|
|
46
|
+
print("Created Routine ID:", created_routine.id)
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
asyncio.run(main())
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 3. Agent Integration
|
|
53
|
+
|
|
54
|
+
Refer to `agent/mcp.yaml` and `agent/actions.yaml` for using this module as a tool in AI workflows via MCP (Model Context Protocol).
|
|
55
|
+
|
|
56
|
+
## Infrastructure
|
|
57
|
+
|
|
58
|
+
We include boilerplate configurations for deployments:
|
|
59
|
+
- `infra/docker-compose.yml`: For local deployments
|
|
60
|
+
- `infra/terraform`: Example Terraform modules for AWS RDS Postgres deployments
|
|
61
|
+
|
|
62
|
+
## Publishing
|
|
63
|
+
|
|
64
|
+
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,8 @@
|
|
|
1
|
+
-- Seed data (optional)
|
|
2
|
+
-- Useful for testing or base categories
|
|
3
|
+
|
|
4
|
+
INSERT INTO schedule_module.category (id, name, description, color, icon)
|
|
5
|
+
VALUES
|
|
6
|
+
('00000000-0000-0000-0000-000000000001', 'Default', 'Default category', '#cccccc', 'default-icon'),
|
|
7
|
+
('00000000-0000-0000-0000-000000000002', 'Work', 'Work related events', '#0000ff', 'work-icon')
|
|
8
|
+
ON CONFLICT DO NOTHING;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
CREATE SCHEMA IF NOT EXISTS schedule_module;
|
|
2
|
+
|
|
3
|
+
DO $$ BEGIN
|
|
4
|
+
CREATE TYPE schedule_module.recurrency_type AS ENUM (
|
|
5
|
+
'daily',
|
|
6
|
+
'weekly',
|
|
7
|
+
'biweekly',
|
|
8
|
+
'monthly',
|
|
9
|
+
'custom_days'
|
|
10
|
+
);
|
|
11
|
+
EXCEPTION
|
|
12
|
+
WHEN duplicate_object THEN null;
|
|
13
|
+
END $$;
|
|
14
|
+
|
|
15
|
+
DO $$ BEGIN
|
|
16
|
+
CREATE TYPE schedule_module.register_type AS ENUM (
|
|
17
|
+
'event',
|
|
18
|
+
'schedule'
|
|
19
|
+
);
|
|
20
|
+
EXCEPTION
|
|
21
|
+
WHEN duplicate_object THEN null;
|
|
22
|
+
END $$;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
-- Full Schema Snapshot
|
|
2
|
+
|
|
3
|
+
CREATE SCHEMA IF NOT EXISTS schedule_module;
|
|
4
|
+
|
|
5
|
+
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
6
|
+
|
|
7
|
+
DO $$ BEGIN
|
|
8
|
+
CREATE TYPE schedule_module.recurrency_type AS ENUM (
|
|
9
|
+
'daily',
|
|
10
|
+
'weekly',
|
|
11
|
+
'biweekly',
|
|
12
|
+
'monthly',
|
|
13
|
+
'custom_days'
|
|
14
|
+
);
|
|
15
|
+
EXCEPTION
|
|
16
|
+
WHEN duplicate_object THEN null;
|
|
17
|
+
END $$;
|
|
18
|
+
|
|
19
|
+
DO $$ BEGIN
|
|
20
|
+
CREATE TYPE schedule_module.register_type AS ENUM (
|
|
21
|
+
'event',
|
|
22
|
+
'schedule'
|
|
23
|
+
);
|
|
24
|
+
EXCEPTION
|
|
25
|
+
WHEN duplicate_object THEN null;
|
|
26
|
+
END $$;
|
|
27
|
+
|
|
28
|
+
CREATE TABLE IF NOT EXISTS schedule_module.schema_version (
|
|
29
|
+
version INT PRIMARY KEY,
|
|
30
|
+
name VARCHAR(255) NOT NULL,
|
|
31
|
+
executed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS schedule_module.category (
|
|
35
|
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
36
|
+
name VARCHAR(255) NOT NULL,
|
|
37
|
+
description TEXT,
|
|
38
|
+
color VARCHAR(50),
|
|
39
|
+
icon VARCHAR(255),
|
|
40
|
+
image VARCHAR(255),
|
|
41
|
+
note TEXT,
|
|
42
|
+
creation_date_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
43
|
+
update_date_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
CREATE TABLE IF NOT EXISTS schedule_module.scheduled_routine (
|
|
47
|
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
48
|
+
name VARCHAR(255) NOT NULL,
|
|
49
|
+
category_id UUID REFERENCES schedule_module.category(id) ON DELETE SET NULL,
|
|
50
|
+
start_date_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
51
|
+
end_date_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
52
|
+
description TEXT,
|
|
53
|
+
note TEXT,
|
|
54
|
+
recurrency schedule_module.recurrency_type NOT NULL,
|
|
55
|
+
creation_date_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
56
|
+
update_date_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE TABLE IF NOT EXISTS schedule_module.date_register (
|
|
60
|
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
61
|
+
type schedule_module.register_type NOT NULL,
|
|
62
|
+
name VARCHAR(255) NOT NULL,
|
|
63
|
+
start_date_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
64
|
+
end_date_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
65
|
+
description TEXT,
|
|
66
|
+
note TEXT,
|
|
67
|
+
category_id UUID REFERENCES schedule_module.category(id) ON DELETE SET NULL,
|
|
68
|
+
scheduled_routine_id UUID REFERENCES schedule_module.scheduled_routine(id) ON DELETE SET NULL,
|
|
69
|
+
relationship_field VARCHAR(255),
|
|
70
|
+
creation_date_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
71
|
+
update_date_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_date_register_relationship ON schedule_module.date_register(relationship_field);
|
|
75
|
+
CREATE INDEX IF NOT EXISTS idx_date_register_start_end ON schedule_module.date_register(start_date_time, end_date_time);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_routine_start_end ON schedule_module.scheduled_routine(start_date_time, end_date_time);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS schedule_module.category (
|
|
2
|
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
3
|
+
name VARCHAR(255) NOT NULL,
|
|
4
|
+
description TEXT,
|
|
5
|
+
color VARCHAR(50),
|
|
6
|
+
icon VARCHAR(255),
|
|
7
|
+
image VARCHAR(255),
|
|
8
|
+
note TEXT,
|
|
9
|
+
creation_date_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
10
|
+
update_date_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
CREATE TABLE IF NOT EXISTS schedule_module.scheduled_routine (
|
|
14
|
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
15
|
+
name VARCHAR(255) NOT NULL,
|
|
16
|
+
category_id UUID REFERENCES schedule_module.category(id) ON DELETE SET NULL,
|
|
17
|
+
start_date_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
18
|
+
end_date_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
19
|
+
description TEXT,
|
|
20
|
+
note TEXT,
|
|
21
|
+
recurrency schedule_module.recurrency_type NOT NULL,
|
|
22
|
+
creation_date_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
23
|
+
update_date_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS schedule_module.date_register (
|
|
27
|
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
28
|
+
type schedule_module.register_type NOT NULL,
|
|
29
|
+
name VARCHAR(255) NOT NULL,
|
|
30
|
+
start_date_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
31
|
+
end_date_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
32
|
+
description TEXT,
|
|
33
|
+
note TEXT,
|
|
34
|
+
category_id UUID REFERENCES schedule_module.category(id) ON DELETE SET NULL,
|
|
35
|
+
scheduled_routine_id UUID REFERENCES schedule_module.scheduled_routine(id) ON DELETE SET NULL,
|
|
36
|
+
relationship_field VARCHAR(255),
|
|
37
|
+
creation_date_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
38
|
+
update_date_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_date_register_relationship ON schedule_module.date_register(relationship_field);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_date_register_start_end ON schedule_module.date_register(start_date_time, end_date_time);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_routine_start_end ON schedule_module.scheduled_routine(start_date_time, end_date_time);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "schedule-module"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A reusable scheduling capability module"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"sqlalchemy[asyncio]>=2.0.0",
|
|
13
|
+
"asyncpg>=0.28.0",
|
|
14
|
+
"pydantic>=2.0.0",
|
|
15
|
+
"python-dateutil>=2.8.2",
|
|
16
|
+
"pytz>=2023.3"
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=7.4.0",
|
|
22
|
+
"pytest-asyncio>=0.21.1",
|
|
23
|
+
"testcontainers[postgres]>=3.7.1"
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
schedule-module-cli = "src.cli:main"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src"]
|
|
@@ -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
|
+
]
|
|
@@ -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()
|
|
@@ -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)
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from src.services.schedule_service import Schedule
|
|
4
|
+
from src.core.models import CategoryCreate
|
|
5
|
+
|
|
6
|
+
@pytest.mark.asyncio
|
|
7
|
+
async def test_create_category():
|
|
8
|
+
# Note: Requires a running postgres test DB to execute successfully
|
|
9
|
+
# For now, this is a placeholder demonstrating the integration testing pattern.
|
|
10
|
+
pass
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from src.utils.date_utils import is_overlap
|
|
4
|
+
|
|
5
|
+
def test_is_overlap():
|
|
6
|
+
start1 = datetime(2023, 1, 1, 10, 0)
|
|
7
|
+
end1 = datetime(2023, 1, 1, 12, 0)
|
|
8
|
+
|
|
9
|
+
start2 = datetime(2023, 1, 1, 11, 0)
|
|
10
|
+
end2 = datetime(2023, 1, 1, 13, 0)
|
|
11
|
+
|
|
12
|
+
assert is_overlap(start1, end1, start2, end2) == True
|
|
13
|
+
|
|
14
|
+
def test_no_overlap():
|
|
15
|
+
start1 = datetime(2023, 1, 1, 10, 0)
|
|
16
|
+
end1 = datetime(2023, 1, 1, 12, 0)
|
|
17
|
+
|
|
18
|
+
start2 = datetime(2023, 1, 1, 13, 0)
|
|
19
|
+
end2 = datetime(2023, 1, 1, 14, 0)
|
|
20
|
+
|
|
21
|
+
assert is_overlap(start1, end1, start2, end2) == False
|