yeonjae-universal-schedule-manager 1.0.1__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.
- universal_schedule_manager/__init__.py +3 -0
- universal_schedule_manager/exceptions.py +69 -0
- universal_schedule_manager/models.py +150 -0
- universal_schedule_manager/service.py +368 -0
- universal_schedule_manager/tasks.py +330 -0
- yeonjae_universal_schedule_manager-1.0.1.dist-info/METADATA +39 -0
- yeonjae_universal_schedule_manager-1.0.1.dist-info/RECORD +9 -0
- yeonjae_universal_schedule_manager-1.0.1.dist-info/WHEEL +5 -0
- yeonjae_universal_schedule_manager-1.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,69 @@
|
|
1
|
+
"""
|
2
|
+
ScheduleManager 모듈의 예외 클래스
|
3
|
+
|
4
|
+
스케줄링 작업과 관련된 모든 예외를 정의합니다.
|
5
|
+
"""
|
6
|
+
|
7
|
+
|
8
|
+
class ScheduleManagerException(Exception):
|
9
|
+
"""ScheduleManager 기본 예외"""
|
10
|
+
def __init__(self, message: str, details: dict = None):
|
11
|
+
self.message = message
|
12
|
+
self.details = details or {}
|
13
|
+
super().__init__(self.message)
|
14
|
+
|
15
|
+
|
16
|
+
class JobNotFoundException(ScheduleManagerException):
|
17
|
+
"""작업을 찾을 수 없는 경우"""
|
18
|
+
def __init__(self, job_id: str):
|
19
|
+
message = f"Job not found: {job_id}"
|
20
|
+
super().__init__(message, {"job_id": job_id})
|
21
|
+
|
22
|
+
|
23
|
+
class SchedulerNotRunningException(ScheduleManagerException):
|
24
|
+
"""스케줄러가 실행되지 않은 경우"""
|
25
|
+
def __init__(self):
|
26
|
+
message = "Scheduler is not running"
|
27
|
+
super().__init__(message)
|
28
|
+
|
29
|
+
|
30
|
+
class JobExecutionException(ScheduleManagerException):
|
31
|
+
"""작업 실행 중 오류가 발생한 경우"""
|
32
|
+
def __init__(self, job_id: str, error: Exception):
|
33
|
+
message = f"Job execution failed: {job_id}"
|
34
|
+
super().__init__(message, {
|
35
|
+
"job_id": job_id,
|
36
|
+
"error_type": type(error).__name__,
|
37
|
+
"error_message": str(error)
|
38
|
+
})
|
39
|
+
|
40
|
+
|
41
|
+
class InvalidScheduleConfigException(ScheduleManagerException):
|
42
|
+
"""잘못된 스케줄 설정인 경우"""
|
43
|
+
def __init__(self, config_field: str, reason: str):
|
44
|
+
message = f"Invalid schedule config - {config_field}: {reason}"
|
45
|
+
super().__init__(message, {
|
46
|
+
"config_field": config_field,
|
47
|
+
"reason": reason
|
48
|
+
})
|
49
|
+
|
50
|
+
|
51
|
+
class InvalidScheduleException(ScheduleManagerException):
|
52
|
+
"""잘못된 스케줄 예외"""
|
53
|
+
def __init__(self, schedule_type: str, expression: str):
|
54
|
+
message = f"Invalid schedule type '{schedule_type}' with expression '{expression}'"
|
55
|
+
super().__init__(message, {
|
56
|
+
"schedule_type": schedule_type,
|
57
|
+
"expression": expression
|
58
|
+
})
|
59
|
+
|
60
|
+
|
61
|
+
class DataRetrievalException(ScheduleManagerException):
|
62
|
+
"""데이터 조회 중 오류가 발생한 경우"""
|
63
|
+
def __init__(self, operation: str, error: Exception):
|
64
|
+
message = f"Data retrieval failed during {operation}"
|
65
|
+
super().__init__(message, {
|
66
|
+
"operation": operation,
|
67
|
+
"error_type": type(error).__name__,
|
68
|
+
"error_message": str(error)
|
69
|
+
})
|
@@ -0,0 +1,150 @@
|
|
1
|
+
"""
|
2
|
+
ScheduleManager 모듈의 데이터 모델
|
3
|
+
|
4
|
+
스케줄링 작업과 관련된 모든 데이터 구조를 정의합니다.
|
5
|
+
"""
|
6
|
+
|
7
|
+
from datetime import datetime
|
8
|
+
from enum import Enum
|
9
|
+
from typing import Dict, List, Optional, Any
|
10
|
+
from pydantic import BaseModel, Field
|
11
|
+
|
12
|
+
|
13
|
+
class ScheduleType(str, Enum):
|
14
|
+
"""스케줄 타입"""
|
15
|
+
CRON = "cron"
|
16
|
+
INTERVAL = "interval"
|
17
|
+
ONE_TIME = "one_time"
|
18
|
+
DAILY = "daily"
|
19
|
+
WEEKLY = "weekly"
|
20
|
+
MONTHLY = "monthly"
|
21
|
+
CUSTOM = "custom"
|
22
|
+
|
23
|
+
|
24
|
+
class ScheduleStatus(str, Enum):
|
25
|
+
"""스케줄 상태"""
|
26
|
+
ACTIVE = "active"
|
27
|
+
INACTIVE = "inactive"
|
28
|
+
PAUSED = "paused"
|
29
|
+
|
30
|
+
|
31
|
+
class JobStatus(str, Enum):
|
32
|
+
"""작업 상태"""
|
33
|
+
SCHEDULED = "scheduled"
|
34
|
+
RUNNING = "running"
|
35
|
+
COMPLETED = "completed"
|
36
|
+
FAILED = "failed"
|
37
|
+
PAUSED = "paused"
|
38
|
+
|
39
|
+
|
40
|
+
class ScheduleConfig(BaseModel):
|
41
|
+
"""스케줄 설정"""
|
42
|
+
schedule_type: ScheduleType = Field(..., description="스케줄 타입")
|
43
|
+
expression: str = Field(..., description="스케줄 표현식 (cron, interval 등)")
|
44
|
+
timezone: str = Field(default="Asia/Seoul", description="시간대 설정")
|
45
|
+
max_instances: int = Field(default=1, description="최대 동시 실행 인스턴스 수")
|
46
|
+
replace_existing: bool = Field(default=True, description="기존 작업 교체 여부")
|
47
|
+
|
48
|
+
|
49
|
+
class JobExecutionInfo(BaseModel):
|
50
|
+
"""작업 실행 정보"""
|
51
|
+
timestamp: datetime = Field(..., description="실행 시간")
|
52
|
+
status: JobStatus = Field(..., description="실행 상태")
|
53
|
+
error_message: Optional[str] = Field(None, description="에러 메시지")
|
54
|
+
execution_time_seconds: Optional[float] = Field(None, description="실행 시간(초)")
|
55
|
+
|
56
|
+
|
57
|
+
class ScheduleJobInfo(BaseModel):
|
58
|
+
"""스케줄 작업 정보"""
|
59
|
+
job_id: str = Field(..., description="작업 ID")
|
60
|
+
name: str = Field(..., description="작업 이름")
|
61
|
+
status: JobStatus = Field(..., description="작업 상태")
|
62
|
+
next_run_time: Optional[datetime] = Field(None, description="다음 실행 시간")
|
63
|
+
last_execution: Optional[JobExecutionInfo] = Field(None, description="마지막 실행 결과")
|
64
|
+
schedule_config: ScheduleConfig = Field(..., description="스케줄 설정")
|
65
|
+
|
66
|
+
|
67
|
+
class CommitData(BaseModel):
|
68
|
+
"""커밋 데이터"""
|
69
|
+
commit_id: str = Field(..., description="커밋 ID")
|
70
|
+
message: str = Field(..., description="커밋 메시지")
|
71
|
+
author: str = Field(..., description="작성자")
|
72
|
+
timestamp: datetime = Field(..., description="커밋 시간")
|
73
|
+
repository: str = Field(..., description="저장소 이름")
|
74
|
+
branch: str = Field(..., description="브랜치명")
|
75
|
+
|
76
|
+
|
77
|
+
class DiffData(BaseModel):
|
78
|
+
"""Diff 데이터"""
|
79
|
+
file_path: str = Field(..., description="파일 경로")
|
80
|
+
additions: int = Field(..., description="추가된 라인 수")
|
81
|
+
deletions: int = Field(..., description="삭제된 라인 수")
|
82
|
+
changes: str = Field(..., description="변경 내용")
|
83
|
+
complexity_score: Optional[float] = Field(None, description="복잡도 점수")
|
84
|
+
language: Optional[str] = Field(None, description="프로그래밍 언어")
|
85
|
+
|
86
|
+
|
87
|
+
class DeveloperSummary(BaseModel):
|
88
|
+
"""개발자 요약 데이터"""
|
89
|
+
developer_id: str = Field(..., description="개발자 ID")
|
90
|
+
developer_name: str = Field(..., description="개발자 이름")
|
91
|
+
commits: List[CommitData] = Field(default_factory=list, description="커밋 목록")
|
92
|
+
diffs: List[DiffData] = Field(default_factory=list, description="diff 목록")
|
93
|
+
statistics: Dict[str, int] = Field(default_factory=dict, description="통계 정보")
|
94
|
+
date_range: Dict[str, str] = Field(..., description="조회 날짜 범위")
|
95
|
+
|
96
|
+
|
97
|
+
class ScheduleExecutionResult(BaseModel):
|
98
|
+
"""스케줄 실행 결과"""
|
99
|
+
job_id: str = Field(..., description="작업 ID")
|
100
|
+
execution_time: datetime = Field(..., description="실행 시간")
|
101
|
+
success: bool = Field(..., description="성공 여부")
|
102
|
+
processed_developers: List[str] = Field(default_factory=list, description="처리된 개발자 목록")
|
103
|
+
error_message: Optional[str] = Field(None, description="에러 메시지")
|
104
|
+
performance_metrics: Dict[str, float] = Field(default_factory=dict, description="성능 메트릭")
|
105
|
+
total_execution_time_seconds: float = Field(..., description="총 실행 시간(초)")
|
106
|
+
|
107
|
+
|
108
|
+
class DailyReportRequest(BaseModel):
|
109
|
+
"""일일 리포트 요청"""
|
110
|
+
target_date: Optional[datetime] = Field(None, description="대상 날짜 (기본값: 어제)")
|
111
|
+
include_statistics: bool = Field(default=True, description="통계 포함 여부")
|
112
|
+
developer_filters: Optional[List[str]] = Field(None, description="개발자 필터")
|
113
|
+
repository_filters: Optional[List[str]] = Field(None, description="저장소 필터")
|
114
|
+
|
115
|
+
|
116
|
+
class ScheduleRequest(BaseModel):
|
117
|
+
"""스케줄 요청"""
|
118
|
+
name: str = Field(..., description="스케줄 이름")
|
119
|
+
description: str = Field(..., description="스케줄 설명")
|
120
|
+
schedule_config: ScheduleConfig = Field(..., description="스케줄 설정")
|
121
|
+
job_data: Dict[str, Any] = Field(default_factory=dict, description="작업 데이터")
|
122
|
+
enabled: bool = Field(default=True, description="활성화 여부")
|
123
|
+
|
124
|
+
|
125
|
+
class ScheduleResponse(BaseModel):
|
126
|
+
"""스케줄 응답"""
|
127
|
+
schedule_id: str = Field(..., description="스케줄 ID")
|
128
|
+
name: str = Field(..., description="스케줄 이름")
|
129
|
+
status: ScheduleStatus = Field(..., description="스케줄 상태")
|
130
|
+
next_run_time: Optional[datetime] = Field(None, description="다음 실행 시간")
|
131
|
+
created_at: datetime = Field(..., description="생성 시간")
|
132
|
+
updated_at: datetime = Field(..., description="수정 시간")
|
133
|
+
|
134
|
+
|
135
|
+
class JobExecution(BaseModel):
|
136
|
+
"""작업 실행"""
|
137
|
+
job_id: str = Field(..., description="작업 ID")
|
138
|
+
schedule_id: str = Field(..., description="스케줄 ID")
|
139
|
+
started_at: datetime = Field(..., description="시작 시간")
|
140
|
+
ended_at: Optional[datetime] = Field(None, description="종료 시간")
|
141
|
+
status: str = Field(..., description="실행 상태")
|
142
|
+
result: Optional[Dict[str, Any]] = Field(None, description="실행 결과")
|
143
|
+
|
144
|
+
|
145
|
+
class WeeklySummaryRequest(BaseModel):
|
146
|
+
"""주간 요약 요청"""
|
147
|
+
start_date: Optional[datetime] = Field(None, description="시작 날짜")
|
148
|
+
end_date: Optional[datetime] = Field(None, description="종료 날짜")
|
149
|
+
include_trends: bool = Field(default=True, description="트렌드 분석 포함 여부")
|
150
|
+
team_grouping: bool = Field(default=False, description="팀별 그룹핑 여부")
|
@@ -0,0 +1,368 @@
|
|
1
|
+
"""
|
2
|
+
ScheduleManager 서비스
|
3
|
+
|
4
|
+
매일 오전 8시에 데이터 분석 및 알림 작업을 예약하고 실행하는 범용 스케줄러입니다.
|
5
|
+
APScheduler를 사용하여 안정적인 스케줄링을 제공하며, 스케줄 상태 모니터링 및 실패 처리를 지원합니다.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import asyncio
|
9
|
+
import logging
|
10
|
+
from datetime import datetime, timedelta
|
11
|
+
from typing import Dict, List, Optional, Callable, Any
|
12
|
+
|
13
|
+
try:
|
14
|
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
15
|
+
from apscheduler.triggers.cron import CronTrigger
|
16
|
+
from apscheduler.jobstores.memory import MemoryJobStore
|
17
|
+
from apscheduler.executors.asyncio import AsyncIOExecutor
|
18
|
+
APSCHEDULER_AVAILABLE = True
|
19
|
+
except ImportError:
|
20
|
+
APSCHEDULER_AVAILABLE = False
|
21
|
+
|
22
|
+
from .models import (
|
23
|
+
ScheduleConfig,
|
24
|
+
JobStatus,
|
25
|
+
ScheduleJobInfo,
|
26
|
+
ScheduleExecutionResult,
|
27
|
+
DeveloperSummary,
|
28
|
+
JobExecutionInfo,
|
29
|
+
DailyReportRequest,
|
30
|
+
WeeklySummaryRequest
|
31
|
+
)
|
32
|
+
from .exceptions import (
|
33
|
+
ScheduleManagerException,
|
34
|
+
JobNotFoundException,
|
35
|
+
SchedulerNotRunningException,
|
36
|
+
JobExecutionException
|
37
|
+
)
|
38
|
+
|
39
|
+
|
40
|
+
class MockScheduler:
|
41
|
+
"""APScheduler가 없을 때 사용하는 Mock 스케줄러"""
|
42
|
+
|
43
|
+
def __init__(self, timezone='Asia/Seoul'):
|
44
|
+
self.timezone = timezone
|
45
|
+
self.jobs = {}
|
46
|
+
self.running = False
|
47
|
+
self.logger = logging.getLogger(__name__)
|
48
|
+
|
49
|
+
def add_job(self, func, trigger=None, id=None, name=None, **kwargs):
|
50
|
+
"""Mock job 추가"""
|
51
|
+
job_info = {
|
52
|
+
'id': id,
|
53
|
+
'name': name,
|
54
|
+
'func': func,
|
55
|
+
'trigger': trigger,
|
56
|
+
'kwargs': kwargs
|
57
|
+
}
|
58
|
+
self.jobs[id] = job_info
|
59
|
+
self.logger.info(f"Mock job added: {id}")
|
60
|
+
return job_info
|
61
|
+
|
62
|
+
def start(self):
|
63
|
+
"""Mock 스케줄러 시작"""
|
64
|
+
self.running = True
|
65
|
+
self.logger.info("Mock scheduler started")
|
66
|
+
|
67
|
+
def shutdown(self, wait=True):
|
68
|
+
"""Mock 스케줄러 중지"""
|
69
|
+
self.running = False
|
70
|
+
self.logger.info("Mock scheduler stopped")
|
71
|
+
|
72
|
+
def get_job(self, job_id):
|
73
|
+
"""Mock job 조회"""
|
74
|
+
return self.jobs.get(job_id)
|
75
|
+
|
76
|
+
def remove_job(self, job_id):
|
77
|
+
"""Mock job 제거"""
|
78
|
+
if job_id in self.jobs:
|
79
|
+
del self.jobs[job_id]
|
80
|
+
self.logger.info(f"Mock job removed: {job_id}")
|
81
|
+
|
82
|
+
|
83
|
+
class ScheduleManagerService:
|
84
|
+
"""스케줄 관리 서비스"""
|
85
|
+
|
86
|
+
def __init__(self, timezone: str = "Asia/Seoul"):
|
87
|
+
"""
|
88
|
+
ScheduleManagerService 초기화
|
89
|
+
|
90
|
+
Args:
|
91
|
+
timezone: 시간대 설정 (기본값: Asia/Seoul)
|
92
|
+
"""
|
93
|
+
self.timezone = timezone
|
94
|
+
self.logger = logging.getLogger(__name__)
|
95
|
+
self.jobs: Dict[str, ScheduleJobInfo] = {}
|
96
|
+
self.execution_history: Dict[str, List[JobExecutionInfo]] = {}
|
97
|
+
|
98
|
+
# APScheduler 설정 또는 Mock 사용
|
99
|
+
if APSCHEDULER_AVAILABLE:
|
100
|
+
jobstores = {
|
101
|
+
'default': MemoryJobStore()
|
102
|
+
}
|
103
|
+
executors = {
|
104
|
+
'default': AsyncIOExecutor()
|
105
|
+
}
|
106
|
+
job_defaults = {
|
107
|
+
'coalesce': False,
|
108
|
+
'max_instances': 3
|
109
|
+
}
|
110
|
+
|
111
|
+
self.scheduler = AsyncIOScheduler(
|
112
|
+
jobstores=jobstores,
|
113
|
+
executors=executors,
|
114
|
+
job_defaults=job_defaults,
|
115
|
+
timezone=timezone
|
116
|
+
)
|
117
|
+
self.logger.info("APScheduler initialized")
|
118
|
+
else:
|
119
|
+
self.scheduler = MockScheduler(timezone)
|
120
|
+
self.logger.warning("APScheduler not available, using mock scheduler")
|
121
|
+
|
122
|
+
async def start(self) -> None:
|
123
|
+
"""
|
124
|
+
스케줄러 시작 및 기본 작업 등록
|
125
|
+
|
126
|
+
메모리에 따라 매일 08:00 Asia/Seoul 시간대에 일일 요약 스케줄러를 등록합니다.
|
127
|
+
"""
|
128
|
+
try:
|
129
|
+
# 스케줄러 시작
|
130
|
+
self.scheduler.start()
|
131
|
+
|
132
|
+
# 기본 일일 리포트 작업 등록 (매일 08:00 Asia/Seoul)
|
133
|
+
await self.add_daily_report_job()
|
134
|
+
|
135
|
+
self.logger.info("ScheduleManager started successfully")
|
136
|
+
|
137
|
+
except Exception as e:
|
138
|
+
self.logger.error(f"Failed to start ScheduleManager: {e}")
|
139
|
+
raise ScheduleManagerException(f"Failed to start scheduler: {str(e)}")
|
140
|
+
|
141
|
+
async def stop(self) -> None:
|
142
|
+
"""스케줄러 중지"""
|
143
|
+
try:
|
144
|
+
if hasattr(self.scheduler, 'running') and self.scheduler.running:
|
145
|
+
self.scheduler.shutdown(wait=True)
|
146
|
+
self.logger.info("ScheduleManager stopped")
|
147
|
+
except Exception as e:
|
148
|
+
self.logger.error(f"Error stopping scheduler: {e}")
|
149
|
+
|
150
|
+
async def add_daily_report_job(self) -> str:
|
151
|
+
"""
|
152
|
+
일일 리포트 작업 등록 (매일 08:00 Asia/Seoul)
|
153
|
+
|
154
|
+
Returns:
|
155
|
+
str: 등록된 작업 ID
|
156
|
+
"""
|
157
|
+
config = ScheduleConfig(
|
158
|
+
schedule_time="0 8 * * *", # 매일 오전 8시
|
159
|
+
job_id="daily_developer_report",
|
160
|
+
function_name="trigger_daily_report",
|
161
|
+
timezone="Asia/Seoul"
|
162
|
+
)
|
163
|
+
|
164
|
+
return await self.add_job(config, self.trigger_daily_report)
|
165
|
+
|
166
|
+
async def add_job(
|
167
|
+
self,
|
168
|
+
config: ScheduleConfig,
|
169
|
+
func: Callable,
|
170
|
+
**kwargs
|
171
|
+
) -> str:
|
172
|
+
"""
|
173
|
+
새로운 스케줄 작업 추가
|
174
|
+
|
175
|
+
Args:
|
176
|
+
config: 스케줄 설정
|
177
|
+
func: 실행할 함수
|
178
|
+
**kwargs: 추가 인자
|
179
|
+
|
180
|
+
Returns:
|
181
|
+
str: 등록된 작업 ID
|
182
|
+
"""
|
183
|
+
try:
|
184
|
+
# 설정 검증
|
185
|
+
self._validate_schedule_config(config)
|
186
|
+
|
187
|
+
# APScheduler에 작업 등록
|
188
|
+
if APSCHEDULER_AVAILABLE:
|
189
|
+
job = self.scheduler.add_job(
|
190
|
+
func=func,
|
191
|
+
trigger=CronTrigger.from_crontab(config.schedule_time, timezone=config.timezone),
|
192
|
+
id=config.job_id,
|
193
|
+
name=f"{config.function_name}_{config.job_id}",
|
194
|
+
replace_existing=config.replace_existing,
|
195
|
+
max_instances=config.max_instances,
|
196
|
+
**kwargs
|
197
|
+
)
|
198
|
+
next_run_time = job.next_run_time
|
199
|
+
else:
|
200
|
+
# Mock 스케줄러 사용
|
201
|
+
job = self.scheduler.add_job(
|
202
|
+
func=func,
|
203
|
+
trigger=config.schedule_time,
|
204
|
+
id=config.job_id,
|
205
|
+
name=f"{config.function_name}_{config.job_id}",
|
206
|
+
**kwargs
|
207
|
+
)
|
208
|
+
next_run_time = None
|
209
|
+
|
210
|
+
# 작업 정보 저장
|
211
|
+
job_info = ScheduleJobInfo(
|
212
|
+
job_id=config.job_id,
|
213
|
+
name=f"{config.function_name}_{config.job_id}",
|
214
|
+
status=JobStatus.SCHEDULED,
|
215
|
+
next_run_time=next_run_time,
|
216
|
+
schedule_config=config
|
217
|
+
)
|
218
|
+
|
219
|
+
self.jobs[config.job_id] = job_info
|
220
|
+
self.execution_history[config.job_id] = []
|
221
|
+
|
222
|
+
self.logger.info(f"Job added successfully: {config.job_id}")
|
223
|
+
return config.job_id
|
224
|
+
|
225
|
+
except Exception as e:
|
226
|
+
self.logger.error(f"Failed to add job {config.job_id}: {e}")
|
227
|
+
raise ScheduleManagerException(f"Failed to add job: {str(e)}")
|
228
|
+
|
229
|
+
def get_job_status(self, job_id: str) -> ScheduleJobInfo:
|
230
|
+
"""
|
231
|
+
작업 상태 조회
|
232
|
+
|
233
|
+
Args:
|
234
|
+
job_id: 작업 ID
|
235
|
+
|
236
|
+
Returns:
|
237
|
+
ScheduleJobInfo: 작업 정보
|
238
|
+
"""
|
239
|
+
if job_id not in self.jobs:
|
240
|
+
raise JobNotFoundException(job_id)
|
241
|
+
|
242
|
+
return self.jobs[job_id]
|
243
|
+
|
244
|
+
def list_jobs(self) -> List[ScheduleJobInfo]:
|
245
|
+
"""
|
246
|
+
모든 작업 목록 조회
|
247
|
+
|
248
|
+
Returns:
|
249
|
+
List[ScheduleJobInfo]: 작업 목록
|
250
|
+
"""
|
251
|
+
return list(self.jobs.values())
|
252
|
+
|
253
|
+
async def trigger_daily_report(self) -> ScheduleExecutionResult:
|
254
|
+
"""
|
255
|
+
일일 개발자 활동 리포트 생성
|
256
|
+
|
257
|
+
Returns:
|
258
|
+
ScheduleExecutionResult: 실행 결과
|
259
|
+
"""
|
260
|
+
job_id = "daily_developer_report"
|
261
|
+
start_time = datetime.now()
|
262
|
+
|
263
|
+
try:
|
264
|
+
self.logger.info("Starting daily report generation")
|
265
|
+
|
266
|
+
# 작업 상태 업데이트
|
267
|
+
if job_id in self.jobs:
|
268
|
+
self.jobs[job_id].status = JobStatus.RUNNING
|
269
|
+
|
270
|
+
# 어제 날짜 범위 계산
|
271
|
+
yesterday = datetime.now() - timedelta(days=1)
|
272
|
+
date_range = {
|
273
|
+
"start": yesterday.replace(hour=0, minute=0, second=0).isoformat(),
|
274
|
+
"end": yesterday.replace(hour=23, minute=59, second=59).isoformat()
|
275
|
+
}
|
276
|
+
|
277
|
+
# DataRetriever를 통한 데이터 조회는 실제 구현 시 추가
|
278
|
+
# 현재는 기본 구조만 구현
|
279
|
+
processed_developers = []
|
280
|
+
|
281
|
+
# 실행 시간 계산
|
282
|
+
execution_time = (datetime.now() - start_time).total_seconds()
|
283
|
+
|
284
|
+
# 실행 결과 생성
|
285
|
+
result = ScheduleExecutionResult(
|
286
|
+
job_id=job_id,
|
287
|
+
execution_time=start_time,
|
288
|
+
success=True,
|
289
|
+
processed_developers=processed_developers,
|
290
|
+
performance_metrics={
|
291
|
+
"execution_time_seconds": execution_time,
|
292
|
+
"processed_count": len(processed_developers)
|
293
|
+
},
|
294
|
+
total_execution_time_seconds=execution_time
|
295
|
+
)
|
296
|
+
|
297
|
+
# 실행 기록 저장
|
298
|
+
execution_info = JobExecutionInfo(
|
299
|
+
timestamp=start_time,
|
300
|
+
status=JobStatus.COMPLETED,
|
301
|
+
execution_time_seconds=execution_time
|
302
|
+
)
|
303
|
+
|
304
|
+
if job_id in self.execution_history:
|
305
|
+
self.execution_history[job_id].append(execution_info)
|
306
|
+
|
307
|
+
# 작업 상태 업데이트
|
308
|
+
if job_id in self.jobs:
|
309
|
+
self.jobs[job_id].status = JobStatus.COMPLETED
|
310
|
+
self.jobs[job_id].last_execution = execution_info
|
311
|
+
|
312
|
+
self.logger.info(f"Daily report completed in {execution_time:.2f}s")
|
313
|
+
return result
|
314
|
+
|
315
|
+
except Exception as e:
|
316
|
+
# 실행 실패 처리
|
317
|
+
execution_time = (datetime.now() - start_time).total_seconds()
|
318
|
+
|
319
|
+
execution_info = JobExecutionInfo(
|
320
|
+
timestamp=start_time,
|
321
|
+
status=JobStatus.FAILED,
|
322
|
+
error_message=str(e),
|
323
|
+
execution_time_seconds=execution_time
|
324
|
+
)
|
325
|
+
|
326
|
+
if job_id in self.execution_history:
|
327
|
+
self.execution_history[job_id].append(execution_info)
|
328
|
+
|
329
|
+
if job_id in self.jobs:
|
330
|
+
self.jobs[job_id].status = JobStatus.FAILED
|
331
|
+
self.jobs[job_id].last_execution = execution_info
|
332
|
+
|
333
|
+
self.logger.error(f"Daily report generation failed: {e}")
|
334
|
+
|
335
|
+
return ScheduleExecutionResult(
|
336
|
+
job_id=job_id,
|
337
|
+
execution_time=start_time,
|
338
|
+
success=False,
|
339
|
+
error_message=str(e),
|
340
|
+
total_execution_time_seconds=execution_time
|
341
|
+
)
|
342
|
+
|
343
|
+
def _validate_schedule_config(self, config: ScheduleConfig) -> None:
|
344
|
+
"""
|
345
|
+
스케줄 설정 검증
|
346
|
+
|
347
|
+
Args:
|
348
|
+
config: 스케줄 설정
|
349
|
+
|
350
|
+
Raises:
|
351
|
+
InvalidScheduleConfigException: 잘못된 설정인 경우
|
352
|
+
"""
|
353
|
+
if not config.job_id:
|
354
|
+
raise InvalidScheduleConfigException("job_id", "Job ID is required")
|
355
|
+
|
356
|
+
if not config.function_name:
|
357
|
+
raise InvalidScheduleConfigException("function_name", "Function name is required")
|
358
|
+
|
359
|
+
if not config.schedule_time:
|
360
|
+
raise InvalidScheduleConfigException("schedule_time", "Schedule time is required")
|
361
|
+
|
362
|
+
# Cron 표현식 기본 검증 (5개 필드)
|
363
|
+
cron_parts = config.schedule_time.split()
|
364
|
+
if len(cron_parts) != 5:
|
365
|
+
raise InvalidScheduleConfigException(
|
366
|
+
"schedule_time",
|
367
|
+
"Invalid cron format. Expected 5 fields: minute hour day month day_of_week"
|
368
|
+
)
|
@@ -0,0 +1,330 @@
|
|
1
|
+
"""Celery tasks for schedule manager."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import logging
|
6
|
+
from datetime import datetime, timedelta
|
7
|
+
from typing import Dict, Any
|
8
|
+
|
9
|
+
from celery import shared_task
|
10
|
+
|
11
|
+
from modules.data_retriever.service import DataRetrieverService
|
12
|
+
from universal_llm_service import LLMService, LLMInput, LLMProvider, ModelConfig
|
13
|
+
from modules.data_aggregator.service import DataAggregatorService
|
14
|
+
from modules.prompt_builder.service import PromptBuilderService
|
15
|
+
from universal_notification_service import NotificationService
|
16
|
+
# Simplified logging for standalone operation
|
17
|
+
def log_module_io(module_name: str, operation: str, data: dict):
|
18
|
+
pass
|
19
|
+
|
20
|
+
logger = logging.getLogger(__name__)
|
21
|
+
|
22
|
+
|
23
|
+
@shared_task(name="schedule_manager.daily_summary_task")
|
24
|
+
def daily_summary_task() -> Dict[str, Any]:
|
25
|
+
"""
|
26
|
+
매일 오전 8시에 실행되는 일일 개발자 활동 요약 작업
|
27
|
+
|
28
|
+
기획서 준수 흐름:
|
29
|
+
ScheduleManager → DataRetriever → DataAggregator → PromptBuilder → LLMService → NotificationService
|
30
|
+
|
31
|
+
Returns:
|
32
|
+
Dict[str, Any]: 실행 결과
|
33
|
+
"""
|
34
|
+
start_time = datetime.now()
|
35
|
+
|
36
|
+
try:
|
37
|
+
logger.info("🌅 Starting daily developer summary at 08:00 Asia/Seoul")
|
38
|
+
|
39
|
+
# 어제 날짜 범위 계산 (Asia/Seoul 기준)
|
40
|
+
from zoneinfo import ZoneInfo
|
41
|
+
seoul_tz = ZoneInfo("Asia/Seoul")
|
42
|
+
now_seoul = datetime.now(seoul_tz)
|
43
|
+
yesterday_start = (now_seoul - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
44
|
+
yesterday_end = (now_seoul - timedelta(days=1)).replace(hour=23, minute=59, second=59, microsecond=999999)
|
45
|
+
|
46
|
+
logger.info(f"📅 Processing data for: {yesterday_start.strftime('%Y-%m-%d')} (Asia/Seoul)")
|
47
|
+
|
48
|
+
# 1️⃣ DataRetriever: 어제 데이터 조회
|
49
|
+
logger.info("📊 DataRetriever: Starting data retrieval")
|
50
|
+
|
51
|
+
log_module_io("DataRetriever", "INPUT", metadata={
|
52
|
+
"operation": "get_daily_summary",
|
53
|
+
"target_date": yesterday_start.isoformat(),
|
54
|
+
"timezone": "Asia/Seoul"
|
55
|
+
})
|
56
|
+
|
57
|
+
data_retriever = DataRetrieverService()
|
58
|
+
retrieved_data = data_retriever.get_daily_summary(target_date=yesterday_start)
|
59
|
+
|
60
|
+
log_module_io("DataRetriever", "OUTPUT", output_data=retrieved_data, metadata={
|
61
|
+
"commits_found": len(retrieved_data.commits),
|
62
|
+
"developers_count": len(set(commit.author for commit in retrieved_data.commits)),
|
63
|
+
"query_time": retrieved_data.metadata.query_time_seconds if retrieved_data.metadata else 0
|
64
|
+
})
|
65
|
+
|
66
|
+
logger.info(
|
67
|
+
"✅ DataRetriever: Found %d records from daily summary",
|
68
|
+
len(retrieved_data.commits)
|
69
|
+
)
|
70
|
+
|
71
|
+
# 데이터가 없으면 종료
|
72
|
+
if not retrieved_data.commits:
|
73
|
+
logger.info("📭 No data found for yesterday. Skipping daily summary.")
|
74
|
+
return {
|
75
|
+
"success": True,
|
76
|
+
"message": "No data found for yesterday",
|
77
|
+
"execution_time_seconds": (datetime.now() - start_time).total_seconds()
|
78
|
+
}
|
79
|
+
|
80
|
+
# 2️⃣ DataAggregator: 개발자별 활동 집계
|
81
|
+
logger.info("🔢 DataAggregator: Starting data aggregation")
|
82
|
+
|
83
|
+
# 데이터를 AggregationInput 형태로 변환
|
84
|
+
from modules.data_aggregator.models import AggregationInput, CommitData, DiffInfo, DateRange
|
85
|
+
|
86
|
+
commits = []
|
87
|
+
for commit_info in retrieved_data.commits:
|
88
|
+
# CommitInfo를 CommitData로 변환
|
89
|
+
commit_data = CommitData(
|
90
|
+
commit_id=commit_info.commit_hash,
|
91
|
+
author=commit_info.author,
|
92
|
+
author_email=commit_info.author_email,
|
93
|
+
timestamp=commit_info.timestamp,
|
94
|
+
message=commit_info.message,
|
95
|
+
repository=commit_info.repository,
|
96
|
+
branch=commit_info.branch,
|
97
|
+
diff_info=[] # 기본값
|
98
|
+
)
|
99
|
+
commits.append(commit_data)
|
100
|
+
|
101
|
+
# DateRange를 문자열 형식으로 생성
|
102
|
+
date_range = DateRange(
|
103
|
+
start=yesterday_start.strftime('%Y-%m-%d'),
|
104
|
+
end=yesterday_end.strftime('%Y-%m-%d')
|
105
|
+
)
|
106
|
+
|
107
|
+
aggregation_input = AggregationInput(
|
108
|
+
commits=commits,
|
109
|
+
date_range=date_range
|
110
|
+
)
|
111
|
+
|
112
|
+
log_module_io("DataAggregator", "INPUT", input_data=aggregation_input, metadata={
|
113
|
+
"operation": "aggregate_data",
|
114
|
+
"input_commits_count": len(commits)
|
115
|
+
})
|
116
|
+
|
117
|
+
data_aggregator = DataAggregatorService()
|
118
|
+
# async 메서드를 동기적으로 실행
|
119
|
+
import asyncio
|
120
|
+
try:
|
121
|
+
# 이미 이벤트 루프가 실행 중인 경우 새 루프 생성
|
122
|
+
loop = asyncio.get_event_loop()
|
123
|
+
if loop.is_running():
|
124
|
+
import concurrent.futures
|
125
|
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
126
|
+
future = executor.submit(asyncio.run, data_aggregator.aggregate_data(aggregation_input))
|
127
|
+
aggregated_data = future.result()
|
128
|
+
else:
|
129
|
+
aggregated_data = asyncio.run(data_aggregator.aggregate_data(aggregation_input))
|
130
|
+
except RuntimeError:
|
131
|
+
# 이벤트 루프가 없는 경우
|
132
|
+
aggregated_data = asyncio.run(data_aggregator.aggregate_data(aggregation_input))
|
133
|
+
|
134
|
+
log_module_io("DataAggregator", "OUTPUT", output_data=aggregated_data, metadata={
|
135
|
+
"developers_processed": len(aggregated_data.developer_stats),
|
136
|
+
"repositories_processed": len(aggregated_data.repository_stats),
|
137
|
+
"time_analysis_completed": aggregated_data.time_analysis is not None,
|
138
|
+
"complexity_metrics_completed": aggregated_data.complexity_metrics is not None
|
139
|
+
})
|
140
|
+
|
141
|
+
logger.info(
|
142
|
+
"✅ DataAggregator: Processed %d developers, %d repositories",
|
143
|
+
len(aggregated_data.developer_stats),
|
144
|
+
len(aggregated_data.repository_stats)
|
145
|
+
)
|
146
|
+
|
147
|
+
# 3️⃣ PromptBuilder: LLM용 프롬프트 생성
|
148
|
+
logger.info("📝 PromptBuilder: Starting prompt generation")
|
149
|
+
|
150
|
+
log_module_io("PromptBuilder", "INPUT", input_data=aggregated_data, metadata={
|
151
|
+
"operation": "build_daily_summary_prompt",
|
152
|
+
"template_type": "daily_developer_summary",
|
153
|
+
"language": "korean"
|
154
|
+
})
|
155
|
+
|
156
|
+
prompt_builder = PromptBuilderService()
|
157
|
+
|
158
|
+
# 전체 개발자 목록에서 첫 번째 개발자를 대상으로 하거나,
|
159
|
+
# 개발자가 없으면 "전체 팀"으로 설정
|
160
|
+
if aggregated_data.developer_stats:
|
161
|
+
# 첫 번째 개발자 선택
|
162
|
+
first_developer = list(aggregated_data.developer_stats.values())[0]
|
163
|
+
target_developer = first_developer.developer
|
164
|
+
else:
|
165
|
+
target_developer = "개발팀"
|
166
|
+
|
167
|
+
# AggregationResult를 딕셔너리로 변환
|
168
|
+
aggregated_dict = {
|
169
|
+
"developer_stats": {
|
170
|
+
email: {
|
171
|
+
"developer": stats.developer,
|
172
|
+
"commit_count": stats.commit_count,
|
173
|
+
"lines_added": stats.lines_added,
|
174
|
+
"lines_deleted": stats.lines_deleted,
|
175
|
+
"files_changed": stats.files_changed,
|
176
|
+
"languages_used": stats.languages_used,
|
177
|
+
"avg_complexity": stats.avg_complexity
|
178
|
+
}
|
179
|
+
for email, stats in aggregated_data.developer_stats.items()
|
180
|
+
}
|
181
|
+
}
|
182
|
+
|
183
|
+
prompt_result = prompt_builder.build_daily_summary_prompt(aggregated_dict, target_developer)
|
184
|
+
|
185
|
+
log_module_io("PromptBuilder", "OUTPUT", output_data=prompt_result, metadata={
|
186
|
+
"prompt_length": len(prompt_result.prompt),
|
187
|
+
"template_used": prompt_result.template_used,
|
188
|
+
"variables_count": len(prompt_result.context_data) if hasattr(prompt_result, 'context_data') else 0
|
189
|
+
})
|
190
|
+
|
191
|
+
logger.info(
|
192
|
+
"✅ PromptBuilder: Generated prompt (%d chars) using template '%s'",
|
193
|
+
len(prompt_result.prompt),
|
194
|
+
prompt_result.template_used
|
195
|
+
)
|
196
|
+
|
197
|
+
# 4️⃣ LLMService: 코드 분석 및 요약 생성
|
198
|
+
logger.info("🤖 LLMService: Starting LLM analysis")
|
199
|
+
|
200
|
+
log_module_io("LLMService", "INPUT", input_data=prompt_result, metadata={
|
201
|
+
"operation": "generate_summary",
|
202
|
+
"prompt_length": len(prompt_result.prompt),
|
203
|
+
"provider": "openai" # 기본값
|
204
|
+
})
|
205
|
+
|
206
|
+
llm_service = LLMService()
|
207
|
+
|
208
|
+
# LLMInput 객체 생성
|
209
|
+
model_config = ModelConfig(
|
210
|
+
model="gpt-3.5-turbo", # 기본 모델
|
211
|
+
temperature=0.7,
|
212
|
+
max_tokens=1000
|
213
|
+
)
|
214
|
+
|
215
|
+
llm_input = LLMInput(
|
216
|
+
prompt=prompt_result.prompt,
|
217
|
+
llm_provider=LLMProvider.OPENAI,
|
218
|
+
model_config=model_config
|
219
|
+
)
|
220
|
+
|
221
|
+
# async 메서드를 동기적으로 실행
|
222
|
+
try:
|
223
|
+
# 이미 이벤트 루프가 실행 중인 경우 새 루프 생성
|
224
|
+
loop = asyncio.get_event_loop()
|
225
|
+
if loop.is_running():
|
226
|
+
import concurrent.futures
|
227
|
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
228
|
+
future = executor.submit(asyncio.run, llm_service.generate_summary(llm_input))
|
229
|
+
llm_response = future.result()
|
230
|
+
else:
|
231
|
+
llm_response = asyncio.run(llm_service.generate_summary(llm_input))
|
232
|
+
except RuntimeError:
|
233
|
+
# 이벤트 루프가 없는 경우
|
234
|
+
llm_response = asyncio.run(llm_service.generate_summary(llm_input))
|
235
|
+
|
236
|
+
log_module_io("LLMService", "OUTPUT", output_data=llm_response, metadata={
|
237
|
+
"response_length": len(llm_response.summary),
|
238
|
+
"confidence_score": llm_response.confidence_score,
|
239
|
+
"provider_used": llm_response.metadata.provider if llm_response.metadata else "unknown",
|
240
|
+
"model_used": llm_response.metadata.model_used if llm_response.metadata else "unknown"
|
241
|
+
})
|
242
|
+
|
243
|
+
logger.info(
|
244
|
+
"✅ LLMService: Generated summary (%d chars) using %s/%s",
|
245
|
+
len(llm_response.summary),
|
246
|
+
llm_response.metadata.provider if llm_response.metadata else "unknown",
|
247
|
+
llm_response.metadata.model_used if llm_response.metadata else "unknown"
|
248
|
+
)
|
249
|
+
|
250
|
+
# 5️⃣ NotificationService: Slack 알림 전송
|
251
|
+
logger.info("📢 NotificationService: Starting notification delivery")
|
252
|
+
|
253
|
+
# 알림 메시지 구성
|
254
|
+
notification_message = f"""🌅 **일일 개발 활동 요약** - {yesterday_start.strftime('%Y년 %m월 %d일')}
|
255
|
+
|
256
|
+
{llm_response.summary}
|
257
|
+
|
258
|
+
---
|
259
|
+
📊 **전체 통계**
|
260
|
+
• 개발자: {len(aggregated_data.developer_stats)}명
|
261
|
+
• 저장소: {len(aggregated_data.repository_stats)}개
|
262
|
+
• 처리된 커밋: {len(commits)}개
|
263
|
+
|
264
|
+
_CodePing.AI 자동 생성 리포트_"""
|
265
|
+
|
266
|
+
log_module_io("NotificationService", "INPUT", metadata={
|
267
|
+
"operation": "send_slack_message",
|
268
|
+
"message_length": len(notification_message),
|
269
|
+
"channel": "default"
|
270
|
+
})
|
271
|
+
|
272
|
+
notification_service = NotificationService()
|
273
|
+
notification_result = notification_service.send_daily_summary(
|
274
|
+
summary_report=notification_message,
|
275
|
+
developer=target_developer,
|
276
|
+
developer_email="team@codeping.ai", # 기본 이메일
|
277
|
+
slack_channel="#dev-reports" # 기본 채널
|
278
|
+
)
|
279
|
+
|
280
|
+
log_module_io("NotificationService", "OUTPUT", output_data=notification_result, metadata={
|
281
|
+
"success": notification_result.success,
|
282
|
+
"channel_used": notification_result.channel,
|
283
|
+
"delivery_time": notification_result.timestamp.isoformat() if notification_result.timestamp else None
|
284
|
+
})
|
285
|
+
|
286
|
+
if notification_result.success:
|
287
|
+
logger.info(
|
288
|
+
"✅ NotificationService: Message sent successfully to %s",
|
289
|
+
notification_result.channel
|
290
|
+
)
|
291
|
+
else:
|
292
|
+
logger.error(
|
293
|
+
"❌ NotificationService: Failed to send message: %s",
|
294
|
+
notification_result.error_message
|
295
|
+
)
|
296
|
+
|
297
|
+
# 실행 완료
|
298
|
+
total_execution_time = (datetime.now() - start_time).total_seconds()
|
299
|
+
|
300
|
+
result = {
|
301
|
+
"success": True,
|
302
|
+
"date_processed": yesterday_start.strftime('%Y-%m-%d'),
|
303
|
+
"developers_count": len(aggregated_data.developer_stats),
|
304
|
+
"commits_count": len(commits),
|
305
|
+
"notification_sent": notification_result.success,
|
306
|
+
"execution_time_seconds": total_execution_time,
|
307
|
+
"modules_executed": ["DataRetriever", "DataAggregator", "PromptBuilder", "LLMService", "NotificationService"]
|
308
|
+
}
|
309
|
+
|
310
|
+
logger.info(
|
311
|
+
"🎉 Daily summary completed successfully in %.2fs: %d developers, %d commits, notification=%s",
|
312
|
+
total_execution_time,
|
313
|
+
len(aggregated_data.developer_stats),
|
314
|
+
len(commits),
|
315
|
+
"✅" if notification_result.success else "❌"
|
316
|
+
)
|
317
|
+
|
318
|
+
return result
|
319
|
+
|
320
|
+
except Exception as exc:
|
321
|
+
total_execution_time = (datetime.now() - start_time).total_seconds()
|
322
|
+
|
323
|
+
logger.exception("❌ Daily summary task failed: %s", exc)
|
324
|
+
|
325
|
+
return {
|
326
|
+
"success": False,
|
327
|
+
"error": str(exc),
|
328
|
+
"error_type": type(exc).__name__,
|
329
|
+
"execution_time_seconds": total_execution_time
|
330
|
+
}
|
@@ -0,0 +1,39 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: yeonjae-universal-schedule-manager
|
3
|
+
Version: 1.0.1
|
4
|
+
Summary: Universal schedule manager module for job scheduling and execution
|
5
|
+
Author-email: Yeonjae <dev@example.com>
|
6
|
+
License: MIT
|
7
|
+
Project-URL: Homepage, https://github.com/yeonjae/universal-modules
|
8
|
+
Project-URL: Repository, https://github.com/yeonjae/universal-modules
|
9
|
+
Project-URL: Issues, https://github.com/yeonjae/universal-modules/issues
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
11
|
+
Classifier: Intended Audience :: Developers
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
18
|
+
Requires-Python: >=3.9
|
19
|
+
Description-Content-Type: text/markdown
|
20
|
+
Requires-Dist: pydantic>=2.0.0
|
21
|
+
Requires-Dist: croniter>=1.0.0
|
22
|
+
Provides-Extra: dev
|
23
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
24
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
25
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
26
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
27
|
+
Requires-Dist: isort>=5.12.0; extra == "dev"
|
28
|
+
Requires-Dist: flake8>=6.0.0; extra == "dev"
|
29
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
30
|
+
|
31
|
+
# Universal schedule manager
|
32
|
+
|
33
|
+
범용 schedule manager 모듈
|
34
|
+
|
35
|
+
## 설치
|
36
|
+
|
37
|
+
```bash
|
38
|
+
pip install git+https://github.com/yeonjae-work/universal-modules.git#subdirectory=packages/universal-schedule-manager
|
39
|
+
```
|
@@ -0,0 +1,9 @@
|
|
1
|
+
universal_schedule_manager/__init__.py,sha256=d1asEdvyEKLqcPgRKWG602TWPjGjZK8_UOX21tfDhiE,147
|
2
|
+
universal_schedule_manager/exceptions.py,sha256=ZIUxSMdHeSt0QI5UCzpYESnffgLHulPvAacKOtyY8oQ,2356
|
3
|
+
universal_schedule_manager/models.py,sha256=nvxvh05gRPCzYrNu6mTe0hSPK4WJp1nQwkGAU55jP2U,6464
|
4
|
+
universal_schedule_manager/service.py,sha256=egt94nxGvTyDNuqcNxnu3bDWye7SDXohdaiKzdw28ZA,12571
|
5
|
+
universal_schedule_manager/tasks.py,sha256=iKP_m4F2SM0r7eU2KI-J2KKhCkoeiPEVNTN7oSo2WHU,13806
|
6
|
+
yeonjae_universal_schedule_manager-1.0.1.dist-info/METADATA,sha256=qRlc5QUreqnlrAP0Qf-utDLDWDaQ5ta84pZ1fQbhJxQ,1483
|
7
|
+
yeonjae_universal_schedule_manager-1.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
8
|
+
yeonjae_universal_schedule_manager-1.0.1.dist-info/top_level.txt,sha256=IvPXSrhGzvT4LCf911hF8U9bUTkrR__hgi45sLP5vvA,27
|
9
|
+
yeonjae_universal_schedule_manager-1.0.1.dist-info/RECORD,,
|
@@ -0,0 +1 @@
|
|
1
|
+
universal_schedule_manager
|