interloper-api 0.49.0__tar.gz → 0.50.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.
Files changed (22) hide show
  1. {interloper_api-0.49.0 → interloper_api-0.50.0}/PKG-INFO +1 -1
  2. {interloper_api-0.49.0 → interloper_api-0.50.0}/pyproject.toml +1 -1
  3. {interloper_api-0.49.0 → interloper_api-0.50.0}/pyproject.toml.orig +1 -1
  4. interloper_api-0.50.0/src/interloper_api/email.py +153 -0
  5. {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/components.py +10 -2
  6. {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/organisations.py +1 -0
  7. interloper_api-0.49.0/src/interloper_api/email.py +0 -79
  8. {interloper_api-0.49.0 → interloper_api-0.50.0}/README.md +0 -0
  9. {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/__init__.py +0 -0
  10. {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/app.py +0 -0
  11. {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/components.py +0 -0
  12. {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/dependencies.py +0 -0
  13. {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/__init__.py +0 -0
  14. {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/admin.py +0 -0
  15. {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/agent.py +0 -0
  16. {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/auth.py +0 -0
  17. {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/backfills.py +0 -0
  18. {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/catalog.py +0 -0
  19. {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/oauth.py +0 -0
  20. {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/runs.py +0 -0
  21. {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/tokens.py +0 -0
  22. {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: interloper-api
3
- Version: 0.49.0
3
+ Version: 0.50.0
4
4
  Summary: Interloper FastAPI routes
5
5
  Author: Guillaume Onfroy
6
6
  Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "interloper-api"
3
- version = "0.49.0"
3
+ version = "0.50.0"
4
4
  description = "Interloper FastAPI routes"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -3,7 +3,7 @@
3
3
  # ###############
4
4
  [project]
5
5
  name = "interloper-api"
6
- version = "0.49.0"
6
+ version = "0.50.0"
7
7
  description = "Interloper FastAPI routes"
8
8
  readme = "README.md"
9
9
  authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
@@ -0,0 +1,153 @@
1
+ """SMTP email utilities for sending invitation emails."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import logging
7
+ import smtplib
8
+ from email.mime.multipart import MIMEMultipart
9
+ from email.mime.text import MIMEText
10
+ from typing import Any
11
+
12
+ from interloper_db.store.auth import INVITATION_EXPIRY_DAYS
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Email clients ignore <style> blocks and external assets, so everything is
17
+ # table-based with inline styles. Colors are the design tokens: navy #0B2A42
18
+ # (header), accent #2D7DF6 (button/links), grays from the app palette.
19
+ _FONT_STACK = "-apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
20
+
21
+
22
+ def render_invite_text(org_name: str, inviter_name: str, invite_url: str) -> str:
23
+ """Render the plain-text alternative of the invitation email."""
24
+ return (
25
+ f"{inviter_name} has invited you to join the {org_name} organisation on Interloper.\n\n"
26
+ f"Accept the invitation:\n{invite_url}\n\n"
27
+ f"This invitation expires in {INVITATION_EXPIRY_DAYS} days. If you weren't expecting it,\n"
28
+ "you can safely ignore this email."
29
+ )
30
+
31
+
32
+ def render_invite_html(org_name: str, inviter_name: str, invite_url: str, logo_url: str | None = None) -> str:
33
+ """Render the HTML body of the invitation email.
34
+
35
+ The logo is a hosted PNG (``logo_url``), not an inline SVG: Gmail and
36
+ Outlook strip ``<svg>`` and block ``data:`` URIs. The text wordmark stays
37
+ next to it so the header still reads when images are blocked.
38
+ """
39
+ org = html.escape(org_name)
40
+ inviter = html.escape(inviter_name)
41
+ url = html.escape(invite_url, quote=True)
42
+ logo = (
43
+ f'<img src="{html.escape(logo_url, quote=True)}" width="26" height="26" alt=""'
44
+ ' style="vertical-align: middle; margin-right: 10px;">'
45
+ if logo_url
46
+ else ""
47
+ )
48
+
49
+ return f"""\
50
+ <!DOCTYPE html>
51
+ <html>
52
+ <body style="margin: 0; padding: 0; background: #fbfbfc;">
53
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #fbfbfc;">
54
+ <tr>
55
+ <td align="center" style="padding: 40px 16px;">
56
+ <table role="presentation" width="560" cellpadding="0" cellspacing="0"
57
+ style="max-width: 560px; width: 100%; background: #ffffff; border: 1px solid #e8e8ec;
58
+ border-radius: 12px; overflow: hidden;">
59
+ <tr>
60
+ <td style="background: #0b2a42; padding: 16px 32px;">
61
+ {logo}<span style="font-family: {_FONT_STACK}; font-size: 17px; font-weight: 600;
62
+ letter-spacing: -0.01em; color: #ffffff; vertical-align: middle;">
63
+ Interloper
64
+ </span>
65
+ </td>
66
+ </tr>
67
+ <tr>
68
+ <td style="padding: 32px;">
69
+ <h1 style="margin: 0 0 12px; font-family: {_FONT_STACK}; font-size: 20px;
70
+ font-weight: 700; letter-spacing: -0.01em; color: #1d1d1f;">
71
+ Join {org} on Interloper
72
+ </h1>
73
+ <p style="margin: 0 0 24px; font-family: {_FONT_STACK}; font-size: 14px;
74
+ line-height: 1.6; color: #6b6b70;">
75
+ <strong style="color: #1d1d1f;">{inviter}</strong> has invited you to join the
76
+ <strong style="color: #1d1d1f;">{org}</strong> organisation on Interloper.
77
+ </p>
78
+ <a href="{url}"
79
+ style="display: inline-block; padding: 11px 22px; background: #2d7df6;
80
+ font-family: {_FONT_STACK}; font-size: 14px; font-weight: 600;
81
+ color: #ffffff; text-decoration: none; border-radius: 10px;">
82
+ Accept invitation
83
+ </a>
84
+ <p style="margin: 24px 0 0; font-family: {_FONT_STACK}; font-size: 12.5px;
85
+ line-height: 1.6; color: #9a9aa0;">
86
+ Or copy and paste this link into your browser:<br>
87
+ <a href="{url}" style="color: #2d7df6; word-break: break-all;">{url}</a>
88
+ </p>
89
+ </td>
90
+ </tr>
91
+ <tr>
92
+ <td style="padding: 16px 32px; border-top: 1px solid #f0f0f3;">
93
+ <p style="margin: 0; font-family: {_FONT_STACK}; font-size: 12.5px;
94
+ line-height: 1.6; color: #9a9aa0;">
95
+ This invitation expires in {INVITATION_EXPIRY_DAYS} days.
96
+ If you weren't expecting it, you can safely ignore this email.
97
+ </p>
98
+ </td>
99
+ </tr>
100
+ </table>
101
+ </td>
102
+ </tr>
103
+ </table>
104
+ </body>
105
+ </html>"""
106
+
107
+
108
+ def send_invite_email(
109
+ smtp_config: Any,
110
+ to: str,
111
+ org_name: str,
112
+ inviter_name: str,
113
+ invite_url: str,
114
+ logo_url: str | None = None,
115
+ ) -> None:
116
+ """Send an organisation invitation email via SMTP.
117
+
118
+ Args:
119
+ smtp_config: SmtpConfig instance with host, port, user, password, from_addr.
120
+ to: Recipient email address.
121
+ org_name: Name of the organisation.
122
+ inviter_name: Display name of the person who sent the invite.
123
+ invite_url: Full URL to accept the invitation.
124
+ logo_url: Absolute URL of the header logo image, if available.
125
+
126
+ Raises:
127
+ RuntimeError: If SMTP is not configured.
128
+ smtplib.SMTPException: If sending fails.
129
+ """
130
+ if not smtp_config.enabled:
131
+ raise RuntimeError("SMTP is not configured. Set smtp.host, smtp.user, and smtp.password.")
132
+
133
+ msg = MIMEMultipart("alternative")
134
+ msg["Subject"] = f"You've been invited to join {org_name} on Interloper"
135
+ msg["From"] = smtp_config.from_addr
136
+ msg["To"] = to
137
+
138
+ msg.attach(MIMEText(render_invite_text(org_name, inviter_name, invite_url), "plain"))
139
+ msg.attach(MIMEText(render_invite_html(org_name, inviter_name, invite_url, logo_url), "html"))
140
+
141
+ logger.info("Sending invite email to %s", to)
142
+
143
+ if smtp_config.port == 465:
144
+ with smtplib.SMTP_SSL(smtp_config.host, smtp_config.port) as server:
145
+ server.login(smtp_config.user, smtp_config.password)
146
+ server.sendmail(smtp_config.from_addr, to, msg.as_string())
147
+ else:
148
+ with smtplib.SMTP(smtp_config.host, smtp_config.port) as server:
149
+ server.starttls()
150
+ server.login(smtp_config.user, smtp_config.password)
151
+ server.sendmail(smtp_config.from_addr, to, msg.as_string())
152
+
153
+ logger.info("Invite email sent to %s", to)
@@ -299,6 +299,8 @@ def update_component(
299
299
  raise HTTPException(status_code=400, detail=str(e))
300
300
  except NotFoundError as e:
301
301
  raise HTTPException(status_code=404, detail=str(e))
302
+ except InUseError as e:
303
+ raise HTTPException(status_code=409, detail={"message": str(e), "used_by": e.referrers})
302
304
  return _to_response(row, store, include_config=True)
303
305
 
304
306
 
@@ -353,9 +355,15 @@ def remove_relation(
353
355
  user: Profile = Depends(get_current_user),
354
356
  store: Store = Depends(get_store),
355
357
  ) -> None:
356
- """Remove a component's relations of one type toward one destination."""
358
+ """Remove a component's relations of one type toward one destination.
359
+
360
+ Refused (400) for required dependency slots — repoint them instead.
361
+ """
357
362
  load_authorized(store.get_component, component_id, user, store, label="Component", minimum="editor")
358
- store.remove_relation(component_id, type=type, dst_id=dst_id)
363
+ try:
364
+ store.remove_relation(component_id, type=type, dst_id=dst_id)
365
+ except ConfigError as e:
366
+ raise HTTPException(status_code=400, detail=str(e))
359
367
 
360
368
 
361
369
  # -- Partition endpoint ---------------------------------------------------------
@@ -101,6 +101,7 @@ def _send_invitation_email(
101
101
  org_name=org_name,
102
102
  inviter_name=inviter_name,
103
103
  invite_url=invite_url,
104
+ logo_url=f"{base_url}/logo-email.png",
104
105
  )
105
106
  except Exception:
106
107
  logger.exception("Failed to send invitation email to %s", email)
@@ -1,79 +0,0 @@
1
- """SMTP email utilities for sending invitation emails."""
2
-
3
- from __future__ import annotations
4
-
5
- import logging
6
- import smtplib
7
- from email.mime.multipart import MIMEMultipart
8
- from email.mime.text import MIMEText
9
- from typing import Any
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
-
14
- def send_invite_email(
15
- smtp_config: Any,
16
- to: str,
17
- org_name: str,
18
- inviter_name: str,
19
- invite_url: str,
20
- ) -> None:
21
- """Send an organisation invitation email via SMTP.
22
-
23
- Args:
24
- smtp_config: SmtpConfig instance with host, port, user, password, from_addr.
25
- to: Recipient email address.
26
- org_name: Name of the organisation.
27
- inviter_name: Display name of the person who sent the invite.
28
- invite_url: Full URL to accept the invitation.
29
-
30
- Raises:
31
- RuntimeError: If SMTP is not configured.
32
- smtplib.SMTPException: If sending fails.
33
- """
34
- if not smtp_config.enabled:
35
- raise RuntimeError("SMTP is not configured. Set smtp.host, smtp.user, and smtp.password.")
36
-
37
- msg = MIMEMultipart("alternative")
38
- msg["Subject"] = f"You've been invited to join {org_name} on Interloper"
39
- msg["From"] = smtp_config.from_addr
40
- msg["To"] = to
41
-
42
- text = (
43
- f"{inviter_name} has invited you to join the {org_name} organisation on Interloper.\n\n"
44
- f"Click the link below to accept the invitation:\n{invite_url}\n\n"
45
- "This invitation expires in 7 days."
46
- )
47
-
48
- html = f"""\
49
- <html>
50
- <body style="font-family: sans-serif; color: #333;">
51
- <h2>You've been invited to join {org_name}</h2>
52
- <p>{inviter_name} has invited you to join the <strong>{org_name}</strong> organisation on Interloper.</p>
53
- <p>
54
- <a href="{invite_url}"
55
- style="display: inline-block; padding: 10px 20px; background: #2563eb;
56
- color: #fff; text-decoration: none; border-radius: 6px;">
57
- Accept Invitation
58
- </a>
59
- </p>
60
- <p style="color: #666; font-size: 0.875rem;">This invitation expires in 7 days.</p>
61
- </body>
62
- </html>"""
63
-
64
- msg.attach(MIMEText(text, "plain"))
65
- msg.attach(MIMEText(html, "html"))
66
-
67
- logger.info("Sending invite email to %s", to)
68
-
69
- if smtp_config.port == 465:
70
- with smtplib.SMTP_SSL(smtp_config.host, smtp_config.port) as server:
71
- server.login(smtp_config.user, smtp_config.password)
72
- server.sendmail(smtp_config.from_addr, to, msg.as_string())
73
- else:
74
- with smtplib.SMTP(smtp_config.host, smtp_config.port) as server:
75
- server.starttls()
76
- server.login(smtp_config.user, smtp_config.password)
77
- server.sendmail(smtp_config.from_addr, to, msg.as_string())
78
-
79
- logger.info("Invite email sent to %s", to)