loop-agent-cli 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.
- app/__init__.py +0 -0
- app/agents/__init__.py +0 -0
- app/agents/graph.py +40 -0
- app/agents/nodes.py +245 -0
- app/agents/state.py +19 -0
- app/api/__init__.py +0 -0
- app/api/candidates.py +49 -0
- app/api/dashboard.py +7 -0
- app/api/outreach.py +7 -0
- app/api/pipelines.py +58 -0
- app/api/positions.py +76 -0
- app/api/router.py +14 -0
- app/api/scheduler.py +7 -0
- app/api/skills.py +7 -0
- app/api/system.py +7 -0
- app/core/__init__.py +0 -0
- app/core/config.py +18 -0
- app/core/exception_handler.py +91 -0
- app/core/exceptions.py +33 -0
- app/core/logging.py +19 -0
- app/database/__init__.py +0 -0
- app/database/base.py +4 -0
- app/database/session.py +20 -0
- app/main.py +72 -0
- app/models/__init__.py +0 -0
- app/models/agent_run.py +18 -0
- app/models/candidate.py +28 -0
- app/models/node_log.py +18 -0
- app/models/outreach_log.py +16 -0
- app/models/pipeline.py +21 -0
- app/models/position.py +22 -0
- app/models/scheduler_job.py +16 -0
- app/models/skill.py +13 -0
- app/models/system_config.py +12 -0
- app/repositories/__init__.py +0 -0
- app/repositories/agent_run.py +74 -0
- app/repositories/candidate.py +84 -0
- app/repositories/node_log.py +57 -0
- app/repositories/outreach_log.py +60 -0
- app/repositories/pipeline.py +80 -0
- app/repositories/position.py +67 -0
- app/repositories/scheduler_job.py +74 -0
- app/schemas/__init__.py +0 -0
- app/schemas/agent_run.py +32 -0
- app/schemas/candidate.py +58 -0
- app/schemas/node_log.py +31 -0
- app/schemas/outreach_log.py +28 -0
- app/schemas/pipeline.py +34 -0
- app/schemas/position.py +49 -0
- app/schemas/scheduler_job.py +29 -0
- app/services/__init__.py +0 -0
- app/services/candidate.py +58 -0
- app/services/dashboard.py +230 -0
- app/services/email.py +116 -0
- app/services/health.py +105 -0
- app/services/pipeline.py +75 -0
- app/services/position.py +36 -0
- app/services/runner.py +292 -0
- app/services/scheduler.py +174 -0
- app/services/score.py +155 -0
- app/services/search.py +92 -0
- app/skills/base.py +30 -0
- app/skills/github.py +106 -0
- app/skills/registry.py +51 -0
- app/tests/__init__.py +3 -0
- app/tests/conftest.py +96 -0
- app/tests/generate_report.py +144 -0
- app/tests/test_candidates.py +158 -0
- app/tests/test_dashboard.py +27 -0
- app/tests/test_outreach.py +15 -0
- app/tests/test_pipelines.py +249 -0
- app/tests/test_positions.py +183 -0
- app/tests/test_scheduler.py +15 -0
- app/tests/test_skills.py +15 -0
- app/tests/test_system.py +35 -0
- app/utils/__init__.py +0 -0
- loop_agent_cli/__init__.py +5 -0
- loop_agent_cli/cli.py +728 -0
- loop_agent_cli/container.py +191 -0
- loop_agent_cli-0.1.0.dist-info/METADATA +202 -0
- loop_agent_cli-0.1.0.dist-info/RECORD +84 -0
- loop_agent_cli-0.1.0.dist-info/WHEEL +5 -0
- loop_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- loop_agent_cli-0.1.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
from typing import Dict, Any, List
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
|
+
from app.repositories.position import PositionRepository
|
|
4
|
+
from app.repositories.agent_run import AgentRunRepository
|
|
5
|
+
from app.repositories.pipeline import PipelineRepository
|
|
6
|
+
from app.repositories.candidate import CandidateRepository
|
|
7
|
+
from app.repositories.node_log import NodeLogRepository
|
|
8
|
+
from app.models.position import Position
|
|
9
|
+
from app.models.agent_run import AgentRun
|
|
10
|
+
from app.models.pipeline import Pipeline
|
|
11
|
+
from app.models.candidate import Candidate
|
|
12
|
+
from app.models.node_log import NodeLog
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DashboardService:
|
|
17
|
+
"""
|
|
18
|
+
Service for retrieving dashboard metrics and statistics
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
position_repo: PositionRepository,
|
|
24
|
+
agent_run_repo: AgentRunRepository,
|
|
25
|
+
pipeline_repo: PipelineRepository,
|
|
26
|
+
candidate_repo: CandidateRepository,
|
|
27
|
+
node_log_repo: NodeLogRepository
|
|
28
|
+
):
|
|
29
|
+
self.position_repo = position_repo
|
|
30
|
+
self.agent_run_repo = agent_run_repo
|
|
31
|
+
self.pipeline_repo = pipeline_repo
|
|
32
|
+
self.candidate_repo = candidate_repo
|
|
33
|
+
self.node_log_repo = node_log_repo
|
|
34
|
+
|
|
35
|
+
async def get_dashboard_summary(self) -> Dict[str, Any]:
|
|
36
|
+
"""
|
|
37
|
+
Get the main dashboard summary metrics
|
|
38
|
+
"""
|
|
39
|
+
# Get today's date for filtering
|
|
40
|
+
today_start = datetime.combine(datetime.today().date(), datetime.min.time())
|
|
41
|
+
|
|
42
|
+
# Count running positions
|
|
43
|
+
active_positions = await self.position_repo.get_all(status="active")
|
|
44
|
+
running_positions_count = len(active_positions)
|
|
45
|
+
|
|
46
|
+
# Count today's agent runs
|
|
47
|
+
all_runs = await self.agent_run_repo.get_all(skip=0, limit=1000)
|
|
48
|
+
today_runs = [run for run in all_runs if run.started_at.date() == datetime.today().date()]
|
|
49
|
+
today_loops = len(today_runs)
|
|
50
|
+
|
|
51
|
+
# Count today's candidates
|
|
52
|
+
all_candidates = await self.candidate_repo.get_all(skip=0, limit=1000)
|
|
53
|
+
today_candidates = [cand for cand in all_candidates if cand.created_at.date() == datetime.today().date()]
|
|
54
|
+
today_candidates_count = len(today_candidates)
|
|
55
|
+
|
|
56
|
+
# Count today's emails sent (from agent runs)
|
|
57
|
+
today_emails = sum(run.emails_sent for run in today_runs)
|
|
58
|
+
|
|
59
|
+
# Count recent errors
|
|
60
|
+
recent_errors = [run for run in all_runs if run.error and run.started_at.date() == datetime.today().date()]
|
|
61
|
+
today_errors = len(recent_errors)
|
|
62
|
+
|
|
63
|
+
# Count replies (candidates who have replied in pipeline)
|
|
64
|
+
replied_candidates = await self.pipeline_repo.get_by_status(None, "replied") # This would need to be filtered by date in practice
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
"running_positions": running_positions_count,
|
|
68
|
+
"today_loops": today_loops,
|
|
69
|
+
"today_candidates": today_candidates_count,
|
|
70
|
+
"today_emails": today_emails,
|
|
71
|
+
"today_replies": len(replied_candidates), # Placeholder
|
|
72
|
+
"today_errors": today_errors
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async def get_running_positions(self) -> List[Dict[str, Any]]:
|
|
76
|
+
"""
|
|
77
|
+
Get information about currently running positions
|
|
78
|
+
"""
|
|
79
|
+
active_positions = await self.position_repo.get_all(status="active")
|
|
80
|
+
result = []
|
|
81
|
+
|
|
82
|
+
for pos in active_positions:
|
|
83
|
+
# Get recent pipeline stats for this position
|
|
84
|
+
pipelines = await self.pipeline_repo.get_by_position(pos.id, skip=0, limit=100)
|
|
85
|
+
|
|
86
|
+
# Calculate stats
|
|
87
|
+
contacted_count = len([p for p in pipelines if p.status == "contacted"])
|
|
88
|
+
|
|
89
|
+
result.append({
|
|
90
|
+
"id": pos.id,
|
|
91
|
+
"title": pos.title,
|
|
92
|
+
"company": pos.company,
|
|
93
|
+
"status": pos.status,
|
|
94
|
+
"last_loop_at": pos.last_loop_at,
|
|
95
|
+
"next_loop_at": pos.next_loop_at,
|
|
96
|
+
"candidate_count": len(pipelines),
|
|
97
|
+
"contacted_count": contacted_count
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
async def get_recent_activity(self) -> List[Dict[str, Any]]:
|
|
103
|
+
"""
|
|
104
|
+
Get recent activity timeline
|
|
105
|
+
"""
|
|
106
|
+
# Get recent agent runs and node logs
|
|
107
|
+
recent_runs = await self.agent_run_repo.get_all(skip=0, limit=20)
|
|
108
|
+
recent_logs = await self.node_log_repo.get_all(skip=0, limit=50)
|
|
109
|
+
|
|
110
|
+
activities = []
|
|
111
|
+
|
|
112
|
+
# Add agent runs to activities
|
|
113
|
+
for run in recent_runs[-10:]: # Last 10 runs
|
|
114
|
+
activities.append({
|
|
115
|
+
"time": run.started_at.strftime("%H:%M"),
|
|
116
|
+
"type": "loop",
|
|
117
|
+
"message": f"Loop completed for position {str(run.position_id)[:8]}",
|
|
118
|
+
"details": {
|
|
119
|
+
"candidates_found": run.candidates_found,
|
|
120
|
+
"candidates_added": run.candidates_added,
|
|
121
|
+
"emails_sent": run.emails_sent
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
# Add node logs to activities
|
|
126
|
+
for log in recent_logs[-10:]: # Last 10 logs
|
|
127
|
+
activities.append({
|
|
128
|
+
"time": log.started_at.strftime("%H:%M"),
|
|
129
|
+
"type": log.node_name,
|
|
130
|
+
"message": f"{log.node_name.capitalize()} node executed",
|
|
131
|
+
"details": {
|
|
132
|
+
"status": log.status,
|
|
133
|
+
"duration_ms": log.duration_ms
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
# Sort by time (most recent first)
|
|
138
|
+
activities.sort(key=lambda x: x["time"], reverse=True)
|
|
139
|
+
|
|
140
|
+
return activities[:20] # Return top 20
|
|
141
|
+
|
|
142
|
+
async def get_recent_errors(self) -> List[Dict[str, Any]]:
|
|
143
|
+
"""
|
|
144
|
+
Get recent errors from agent runs and node logs
|
|
145
|
+
"""
|
|
146
|
+
all_runs = await self.agent_run_repo.get_all(skip=0, limit=100)
|
|
147
|
+
all_logs = await self.node_log_repo.get_all(skip=0, limit=100)
|
|
148
|
+
|
|
149
|
+
errors = []
|
|
150
|
+
|
|
151
|
+
# Get errors from agent runs
|
|
152
|
+
for run in all_runs:
|
|
153
|
+
if run.error:
|
|
154
|
+
errors.append({
|
|
155
|
+
"time": run.started_at.strftime("%H:%M"),
|
|
156
|
+
"source": "agent_run",
|
|
157
|
+
"message": run.error,
|
|
158
|
+
"position_id": str(run.position_id)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
# Get errors from node logs
|
|
162
|
+
for log in all_logs:
|
|
163
|
+
if log.error:
|
|
164
|
+
errors.append({
|
|
165
|
+
"time": log.started_at.strftime("%H:%M"),
|
|
166
|
+
"source": log.node_name,
|
|
167
|
+
"message": log.error,
|
|
168
|
+
"position_id": "unknown" # Would need to trace back to position
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
# Sort by time (most recent first)
|
|
172
|
+
errors.sort(key=lambda x: x["time"], reverse=True)
|
|
173
|
+
|
|
174
|
+
return errors[:10] # Return top 10
|
|
175
|
+
|
|
176
|
+
async def get_position_stats(self, position_id: uuid.UUID) -> Dict[str, Any]:
|
|
177
|
+
"""
|
|
178
|
+
Get detailed statistics for a specific position
|
|
179
|
+
"""
|
|
180
|
+
position = await self.position_repo.get_by_id(position_id)
|
|
181
|
+
if not position:
|
|
182
|
+
return {}
|
|
183
|
+
|
|
184
|
+
# Get pipelines for this position
|
|
185
|
+
pipelines = await self.pipeline_repo.get_by_position(position_id, skip=0, limit=1000)
|
|
186
|
+
|
|
187
|
+
# Count by status
|
|
188
|
+
status_counts = {}
|
|
189
|
+
for pipeline in pipelines:
|
|
190
|
+
status = pipeline.status
|
|
191
|
+
status_counts[status] = status_counts.get(status, 0) + 1
|
|
192
|
+
|
|
193
|
+
# Get related agent runs
|
|
194
|
+
runs = await self.agent_run_repo.get_by_position(position_id, skip=0, limit=100)
|
|
195
|
+
|
|
196
|
+
# Calculate metrics
|
|
197
|
+
total_candidates = len(pipelines)
|
|
198
|
+
contacted_count = status_counts.get("contacted", 0)
|
|
199
|
+
replied_count = status_counts.get("replied", 0)
|
|
200
|
+
total_emails_sent = sum(run.emails_sent for run in runs)
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
"position": {
|
|
204
|
+
"id": position.id,
|
|
205
|
+
"title": position.title,
|
|
206
|
+
"company": position.company,
|
|
207
|
+
"status": position.status,
|
|
208
|
+
"created_at": position.created_at
|
|
209
|
+
},
|
|
210
|
+
"pipeline_stats": status_counts,
|
|
211
|
+
"metrics": {
|
|
212
|
+
"total_candidates": total_candidates,
|
|
213
|
+
"contacted": contacted_count,
|
|
214
|
+
"replied": replied_count,
|
|
215
|
+
"total_emails_sent": total_emails_sent,
|
|
216
|
+
"total_runs": len(runs)
|
|
217
|
+
},
|
|
218
|
+
"recent_runs": [
|
|
219
|
+
{
|
|
220
|
+
"id": run.id,
|
|
221
|
+
"started_at": run.started_at,
|
|
222
|
+
"finished_at": run.finished_at,
|
|
223
|
+
"duration_ms": run.duration_ms,
|
|
224
|
+
"candidates_found": run.candidates_found,
|
|
225
|
+
"candidates_added": run.candidates_added,
|
|
226
|
+
"emails_sent": run.emails_sent,
|
|
227
|
+
"status": run.status
|
|
228
|
+
} for run in runs[-5:] # Last 5 runs
|
|
229
|
+
]
|
|
230
|
+
}
|
app/services/email.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import smtplib
|
|
2
|
+
from email.mime.text import MIMEText
|
|
3
|
+
from email.mime.multipart import MIMEMultipart
|
|
4
|
+
from app.core.config import settings
|
|
5
|
+
from app.core.exceptions import SMTPException
|
|
6
|
+
from app.repositories.outreach_log import OutreachLogRepository
|
|
7
|
+
from app.models.outreach_log import OutreachLog
|
|
8
|
+
from app.schemas.outreach_log import OutreachLogCreate
|
|
9
|
+
import asyncio
|
|
10
|
+
from typing import Optional
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EmailService:
|
|
15
|
+
"""
|
|
16
|
+
Service for sending emails to candidates
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, outreach_log_repo: OutreachLogRepository):
|
|
20
|
+
self.outreach_log_repo = outreach_log_repo
|
|
21
|
+
self.smtp_host = settings.smtp_host
|
|
22
|
+
self.smtp_user = settings.smtp_user
|
|
23
|
+
self.smtp_password = settings.smtp_password
|
|
24
|
+
self.smtp_port = settings.smtp_port
|
|
25
|
+
self.sender_email = settings.EMAIL_FROM
|
|
26
|
+
|
|
27
|
+
async def send_email(self, recipient_email: str, subject: str, body: str, pipeline_id: uuid.UUID) -> bool:
|
|
28
|
+
"""
|
|
29
|
+
Send an email to a candidate and log the result
|
|
30
|
+
"""
|
|
31
|
+
# Create the outreach log entry
|
|
32
|
+
outreach_log_data = OutreachLogCreate(
|
|
33
|
+
pipeline_id=pipeline_id,
|
|
34
|
+
subject=subject,
|
|
35
|
+
body=body,
|
|
36
|
+
status="pending"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
outreach_log = await self.outreach_log_repo.create(outreach_log_data)
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
# Create message
|
|
43
|
+
msg = MIMEMultipart()
|
|
44
|
+
msg['From'] = self.sender_email
|
|
45
|
+
msg['To'] = recipient_email
|
|
46
|
+
msg['Subject'] = subject
|
|
47
|
+
|
|
48
|
+
# Add body to email
|
|
49
|
+
msg.attach(MIMEText(body, 'html'))
|
|
50
|
+
|
|
51
|
+
# Create SMTP session
|
|
52
|
+
server = smtplib.SMTP(self.smtp_host, self.smtp_port)
|
|
53
|
+
server.starttls() # Enable encryption
|
|
54
|
+
server.login(self.smtp_user, self.smtp_password)
|
|
55
|
+
|
|
56
|
+
# Send email
|
|
57
|
+
text = msg.as_string()
|
|
58
|
+
server.sendmail(self.sender_email, recipient_email, text)
|
|
59
|
+
server.quit()
|
|
60
|
+
|
|
61
|
+
# Update outreach log status to sent
|
|
62
|
+
await self.outreach_log_repo.update_status(outreach_log.id, "sent")
|
|
63
|
+
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
except Exception as e:
|
|
67
|
+
# Update outreach log status to failed
|
|
68
|
+
await self.outreach_log_repo.update_status_with_error(outreach_log.id, "failed", str(e))
|
|
69
|
+
raise SMTPException(f"Failed to send email: {str(e)}")
|
|
70
|
+
|
|
71
|
+
async def send_bulk_emails(self, recipients: list, subject: str, body: str) -> dict:
|
|
72
|
+
"""
|
|
73
|
+
Send bulk emails and return statistics
|
|
74
|
+
"""
|
|
75
|
+
stats = {
|
|
76
|
+
"total": len(recipients),
|
|
77
|
+
"sent": 0,
|
|
78
|
+
"failed": 0
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for recipient in recipients:
|
|
82
|
+
try:
|
|
83
|
+
success = await self.send_email(recipient["email"], subject, body, recipient["pipeline_id"])
|
|
84
|
+
if success:
|
|
85
|
+
stats["sent"] += 1
|
|
86
|
+
else:
|
|
87
|
+
stats["failed"] += 1
|
|
88
|
+
except Exception:
|
|
89
|
+
stats["failed"] += 1
|
|
90
|
+
|
|
91
|
+
# Small delay to avoid overwhelming the SMTP server
|
|
92
|
+
await asyncio.sleep(1)
|
|
93
|
+
|
|
94
|
+
return stats
|
|
95
|
+
|
|
96
|
+
def generate_email_template(self, candidate_name: str, position_title: str, company_name: str) -> str:
|
|
97
|
+
"""
|
|
98
|
+
Generate a personalized email template
|
|
99
|
+
"""
|
|
100
|
+
template = f"""
|
|
101
|
+
<html>
|
|
102
|
+
<body>
|
|
103
|
+
<p>Dear {candidate_name},</p>
|
|
104
|
+
|
|
105
|
+
<p>I came across your profile on GitHub and noticed your expertise in technologies that align with our current opening for a {position_title} at {company_name}. Given your impressive contributions, I thought this opportunity might interest you.</p>
|
|
106
|
+
|
|
107
|
+
<p>We are looking for someone with skills in {position_title.split()[0] if position_title else 'software development'} and would love to discuss how you could contribute to our team.</p>
|
|
108
|
+
|
|
109
|
+
<p>Would you be interested in learning more about this opportunity?</p>
|
|
110
|
+
|
|
111
|
+
<p>Best regards,<br/>
|
|
112
|
+
Recruiting Loop Agent</p>
|
|
113
|
+
</body>
|
|
114
|
+
</html>
|
|
115
|
+
"""
|
|
116
|
+
return template
|
app/services/health.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from typing import Dict, Any
|
|
2
|
+
from app.database.session import engine
|
|
3
|
+
from sqlalchemy import text
|
|
4
|
+
import httpx
|
|
5
|
+
import asyncio
|
|
6
|
+
from app.core.config import settings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HealthService:
|
|
10
|
+
"""
|
|
11
|
+
Service for checking the health of various system components
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
async def check_database(self) -> Dict[str, Any]:
|
|
15
|
+
"""
|
|
16
|
+
Check database connectivity
|
|
17
|
+
"""
|
|
18
|
+
try:
|
|
19
|
+
async with engine.begin() as conn:
|
|
20
|
+
await conn.execute(text("SELECT 1"))
|
|
21
|
+
return {"status": "connected", "message": "Database is accessible"}
|
|
22
|
+
except Exception as e:
|
|
23
|
+
return {"status": "error", "message": f"Database connection failed: {str(e)}"}
|
|
24
|
+
|
|
25
|
+
async def check_scheduler(self) -> Dict[str, Any]:
|
|
26
|
+
"""
|
|
27
|
+
Check scheduler status
|
|
28
|
+
"""
|
|
29
|
+
# For now, just return a placeholder
|
|
30
|
+
# In a real implementation, this would check the actual scheduler status
|
|
31
|
+
return {"status": "running", "message": "Scheduler is operational"}
|
|
32
|
+
|
|
33
|
+
async def check_github(self) -> Dict[str, Any]:
|
|
34
|
+
"""
|
|
35
|
+
Check GitHub API connectivity
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
if not settings.github_token:
|
|
39
|
+
return {"status": "warning", "message": "GitHub token not configured"}
|
|
40
|
+
|
|
41
|
+
headers = {
|
|
42
|
+
"Authorization": f"token {settings.github_token}",
|
|
43
|
+
"Accept": "application/vnd.github.v3+json"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async with httpx.AsyncClient() as client:
|
|
47
|
+
response = await client.get("https://api.github.com/user", headers=headers)
|
|
48
|
+
|
|
49
|
+
if response.status_code == 200:
|
|
50
|
+
return {"status": "connected", "message": "GitHub API is accessible"}
|
|
51
|
+
else:
|
|
52
|
+
return {"status": "error", "message": f"GitHub API returned status {response.status_code}"}
|
|
53
|
+
except Exception as e:
|
|
54
|
+
return {"status": "error", "message": f"GitHub API connection failed: {str(e)}"}
|
|
55
|
+
|
|
56
|
+
async def check_smtp(self) -> Dict[str, Any]:
|
|
57
|
+
"""
|
|
58
|
+
Check SMTP connectivity
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
if not settings.smtp_host or not settings.smtp_user or not settings.smtp_password:
|
|
62
|
+
return {"status": "warning", "message": "SMTP credentials not fully configured"}
|
|
63
|
+
|
|
64
|
+
# For now, just check if the config is available
|
|
65
|
+
# In a real implementation, we'd try to connect to the SMTP server
|
|
66
|
+
return {"status": "configured", "message": "SMTP is configured"}
|
|
67
|
+
except Exception as e:
|
|
68
|
+
return {"status": "error", "message": f"SMTP configuration error: {str(e)}"}
|
|
69
|
+
|
|
70
|
+
async def get_full_health_status(self) -> Dict[str, Any]:
|
|
71
|
+
"""
|
|
72
|
+
Get comprehensive health status of all system components
|
|
73
|
+
"""
|
|
74
|
+
# Run all health checks concurrently
|
|
75
|
+
db_task = self.check_database()
|
|
76
|
+
scheduler_task = self.check_scheduler()
|
|
77
|
+
github_task = self.check_github()
|
|
78
|
+
smtp_task = self.check_smtp()
|
|
79
|
+
|
|
80
|
+
results = await asyncio.gather(
|
|
81
|
+
db_task, scheduler_task, github_task, smtp_task,
|
|
82
|
+
return_exceptions=True
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Process results
|
|
86
|
+
health_status = {
|
|
87
|
+
"database": results[0] if not isinstance(results[0], Exception) else {"status": "error", "message": str(results[0])},
|
|
88
|
+
"scheduler": results[1] if not isinstance(results[1], Exception) else {"status": "error", "message": str(results[1])},
|
|
89
|
+
"github": results[2] if not isinstance(results[2], Exception) else {"status": "error", "message": str(results[2])},
|
|
90
|
+
"smtp": results[3] if not isinstance(results[3], Exception) else {"status": "error", "message": str(results[3])},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# Determine overall status
|
|
94
|
+
overall_status = "healthy"
|
|
95
|
+
for component, status_info in health_status.items():
|
|
96
|
+
if status_info["status"] == "error":
|
|
97
|
+
overall_status = "error"
|
|
98
|
+
break
|
|
99
|
+
elif status_info["status"] == "warning" and overall_status != "error":
|
|
100
|
+
overall_status = "warning"
|
|
101
|
+
|
|
102
|
+
health_status["overall"] = overall_status
|
|
103
|
+
health_status["timestamp"] = asyncio.get_event_loop().time()
|
|
104
|
+
|
|
105
|
+
return health_status
|
app/services/pipeline.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from typing import List, Optional
|
|
2
|
+
from app.repositories.pipeline import PipelineRepository
|
|
3
|
+
from app.schemas.pipeline import PipelineCreate, PipelineUpdate, Pipeline
|
|
4
|
+
from app.core.exceptions import PipelineNotFoundException
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
class PipelineService:
|
|
8
|
+
def __init__(self, pipeline_repo: PipelineRepository):
|
|
9
|
+
self.pipeline_repo = pipeline_repo
|
|
10
|
+
|
|
11
|
+
async def create_pipeline(self, pipeline_data: PipelineCreate) -> Pipeline:
|
|
12
|
+
# Check if pipeline already exists for this position and candidate
|
|
13
|
+
existing_pipeline = await self.pipeline_repo.get_by_position_and_candidate(
|
|
14
|
+
pipeline_data.position_id,
|
|
15
|
+
pipeline_data.candidate_id
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
if existing_pipeline:
|
|
19
|
+
# Return existing pipeline instead of creating duplicate
|
|
20
|
+
return existing_pipeline
|
|
21
|
+
|
|
22
|
+
return await self.pipeline_repo.create(pipeline_data)
|
|
23
|
+
|
|
24
|
+
async def get_pipeline_by_id(self, pipeline_id: uuid.UUID) -> Pipeline:
|
|
25
|
+
pipeline = await self.pipeline_repo.get_by_id(pipeline_id)
|
|
26
|
+
if not pipeline:
|
|
27
|
+
raise PipelineNotFoundException(f"Pipeline with id {pipeline_id} not found")
|
|
28
|
+
return pipeline
|
|
29
|
+
|
|
30
|
+
async def get_pipeline_by_position_and_candidate(self, position_id: uuid.UUID, candidate_id: uuid.UUID) -> Optional[Pipeline]:
|
|
31
|
+
return await self.pipeline_repo.get_by_position_and_candidate(position_id, candidate_id)
|
|
32
|
+
|
|
33
|
+
async def get_pipelines_by_position(self, position_id: uuid.UUID, skip: int = 0, limit: int = 100) -> List[Pipeline]:
|
|
34
|
+
return await self.pipeline_repo.get_by_position(position_id, skip, limit)
|
|
35
|
+
|
|
36
|
+
async def get_pipelines_by_status(self, position_id: uuid.UUID, status: str) -> List[Pipeline]:
|
|
37
|
+
return await self.pipeline_repo.get_by_status(position_id, status)
|
|
38
|
+
|
|
39
|
+
async def get_all_pipelines(self, skip: int = 0, limit: int = 100) -> List[Pipeline]:
|
|
40
|
+
return await self.pipeline_repo.get_all(skip, limit)
|
|
41
|
+
|
|
42
|
+
async def update_pipeline(self, pipeline_id: uuid.UUID, pipeline_data: PipelineUpdate) -> Optional[Pipeline]:
|
|
43
|
+
return await self.pipeline_repo.update(pipeline_id, pipeline_data)
|
|
44
|
+
|
|
45
|
+
async def update_pipeline_status(self, pipeline_id: uuid.UUID, status: str) -> Optional[Pipeline]:
|
|
46
|
+
return await self.pipeline_repo.update_status(pipeline_id, status)
|
|
47
|
+
|
|
48
|
+
async def delete_pipeline(self, pipeline_id: uuid.UUID) -> bool:
|
|
49
|
+
return await self.pipeline_repo.delete(pipeline_id)
|
|
50
|
+
|
|
51
|
+
async def move_to_next_stage(self, pipeline_id: uuid.UUID) -> Optional[Pipeline]:
|
|
52
|
+
"""
|
|
53
|
+
Move pipeline to the next stage in the recruitment process
|
|
54
|
+
discovered -> contacted -> replied -> interview -> offer -> rejected
|
|
55
|
+
"""
|
|
56
|
+
pipeline = await self.get_pipeline_by_id(pipeline_id)
|
|
57
|
+
if pipeline:
|
|
58
|
+
current_status = pipeline.status
|
|
59
|
+
if current_status == "discovered":
|
|
60
|
+
next_status = "contacted"
|
|
61
|
+
elif current_status == "contacted":
|
|
62
|
+
next_status = "replied"
|
|
63
|
+
elif current_status == "replied":
|
|
64
|
+
next_status = "interview"
|
|
65
|
+
elif current_status == "interview":
|
|
66
|
+
next_status = "offer"
|
|
67
|
+
elif current_status == "offer":
|
|
68
|
+
next_status = "rejected"
|
|
69
|
+
else:
|
|
70
|
+
next_status = current_status
|
|
71
|
+
|
|
72
|
+
if current_status != next_status:
|
|
73
|
+
return await self.update_pipeline_status(pipeline_id, next_status)
|
|
74
|
+
|
|
75
|
+
return pipeline
|
app/services/position.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from typing import List, Optional
|
|
2
|
+
from app.repositories.position import PositionRepository
|
|
3
|
+
from app.schemas.position import PositionCreate, PositionUpdate, Position
|
|
4
|
+
from app.core.exceptions import PositionNotFoundException
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
class PositionService:
|
|
8
|
+
def __init__(self, position_repo: PositionRepository):
|
|
9
|
+
self.position_repo = position_repo
|
|
10
|
+
|
|
11
|
+
async def create_position(self, position_data: PositionCreate) -> Position:
|
|
12
|
+
return await self.position_repo.create(position_data)
|
|
13
|
+
|
|
14
|
+
async def get_position_by_id(self, position_id: uuid.UUID) -> Position:
|
|
15
|
+
position = await self.position_repo.get_by_id(position_id)
|
|
16
|
+
if not position:
|
|
17
|
+
raise PositionNotFoundException(f"Position with id {position_id} not found")
|
|
18
|
+
return position
|
|
19
|
+
|
|
20
|
+
async def get_all_positions(self, skip: int = 0, limit: int = 100, status: Optional[str] = None) -> List[Position]:
|
|
21
|
+
return await self.position_repo.get_all(skip, limit, status)
|
|
22
|
+
|
|
23
|
+
async def update_position(self, position_id: uuid.UUID, position_data: PositionUpdate) -> Optional[Position]:
|
|
24
|
+
return await self.position_repo.update(position_id, position_data)
|
|
25
|
+
|
|
26
|
+
async def delete_position(self, position_id: uuid.UUID) -> bool:
|
|
27
|
+
return await self.position_repo.delete(position_id)
|
|
28
|
+
|
|
29
|
+
async def pause_position(self, position_id: uuid.UUID) -> Optional[Position]:
|
|
30
|
+
return await self.position_repo.update_status(position_id, "paused")
|
|
31
|
+
|
|
32
|
+
async def resume_position(self, position_id: uuid.UUID) -> Optional[Position]:
|
|
33
|
+
return await self.position_repo.update_status(position_id, "active")
|
|
34
|
+
|
|
35
|
+
async def close_position(self, position_id: uuid.UUID) -> Optional[Position]:
|
|
36
|
+
return await self.position_repo.update_status(position_id, "closed")
|