Skip to content

Commit fde623e

Browse files
First attempt at webhooks
1 parent 61c540e commit fde623e

File tree

8 files changed

+1095
-24
lines changed

8 files changed

+1095
-24
lines changed

app/__init__.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,126 @@ def timeout(seconds):
2828
signal.signal(signal.SIGALRM, old_handler)
2929
signal.alarm(0)
3030

31+
def migrate_database():
32+
"""Automatically migrate database schema to support new features"""
33+
try:
34+
from sqlalchemy import text, inspect
35+
36+
inspector = inspect(db.engine)
37+
38+
# Detect database type for appropriate SQL syntax
39+
db_dialect = db.engine.dialect.name
40+
print(f"🔍 Detected database: {db_dialect}")
41+
42+
# Check if webhook_notifications column exists in user_settings table
43+
user_settings_columns = [col['name'] for col in inspector.get_columns('user_settings')]
44+
45+
migrations_applied = []
46+
47+
# Migration 1: Add webhook_notifications column to user_settings
48+
if 'webhook_notifications' not in user_settings_columns:
49+
try:
50+
# Use appropriate SQL for different databases
51+
if db_dialect == 'postgresql':
52+
alter_sql = 'ALTER TABLE user_settings ADD COLUMN webhook_notifications BOOLEAN DEFAULT FALSE'
53+
elif db_dialect == 'mysql':
54+
alter_sql = 'ALTER TABLE user_settings ADD COLUMN webhook_notifications BOOLEAN DEFAULT FALSE'
55+
else: # SQLite
56+
alter_sql = 'ALTER TABLE user_settings ADD COLUMN webhook_notifications BOOLEAN DEFAULT FALSE'
57+
58+
with db.engine.connect() as conn:
59+
conn.execute(text(alter_sql))
60+
conn.commit()
61+
migrations_applied.append("Added webhook_notifications column to user_settings")
62+
except Exception as e:
63+
print(f"⚠️ Could not add webhook_notifications column (may already exist): {e}")
64+
65+
# Migration 2: Create webhook table if it doesn't exist
66+
if not inspector.has_table('webhook'):
67+
try:
68+
# Create webhook table with database-specific syntax
69+
if db_dialect == 'postgresql':
70+
create_webhook_table = text("""
71+
CREATE TABLE webhook (
72+
id SERIAL PRIMARY KEY,
73+
name VARCHAR(100) NOT NULL,
74+
webhook_type VARCHAR(50) NOT NULL,
75+
url VARCHAR(500) NOT NULL,
76+
auth_header VARCHAR(200),
77+
auth_username VARCHAR(100),
78+
auth_password VARCHAR(200),
79+
custom_headers TEXT,
80+
is_active BOOLEAN DEFAULT TRUE,
81+
user_id INTEGER NOT NULL,
82+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
83+
last_used TIMESTAMP,
84+
FOREIGN KEY (user_id) REFERENCES "user"(id)
85+
)
86+
""")
87+
elif db_dialect == 'mysql':
88+
create_webhook_table = text("""
89+
CREATE TABLE webhook (
90+
id INT AUTO_INCREMENT PRIMARY KEY,
91+
name VARCHAR(100) NOT NULL,
92+
webhook_type VARCHAR(50) NOT NULL,
93+
url VARCHAR(500) NOT NULL,
94+
auth_header VARCHAR(200),
95+
auth_username VARCHAR(100),
96+
auth_password VARCHAR(200),
97+
custom_headers TEXT,
98+
is_active BOOLEAN DEFAULT TRUE,
99+
user_id INTEGER NOT NULL,
100+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
101+
last_used DATETIME,
102+
FOREIGN KEY (user_id) REFERENCES user(id)
103+
)
104+
""")
105+
else: # SQLite
106+
create_webhook_table = text("""
107+
CREATE TABLE webhook (
108+
id INTEGER PRIMARY KEY AUTOINCREMENT,
109+
name VARCHAR(100) NOT NULL,
110+
webhook_type VARCHAR(50) NOT NULL,
111+
url VARCHAR(500) NOT NULL,
112+
auth_header VARCHAR(200),
113+
auth_username VARCHAR(100),
114+
auth_password VARCHAR(200),
115+
custom_headers TEXT,
116+
is_active BOOLEAN DEFAULT 1,
117+
user_id INTEGER NOT NULL,
118+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
119+
last_used DATETIME,
120+
FOREIGN KEY (user_id) REFERENCES user(id)
121+
)
122+
""")
123+
124+
with db.engine.connect() as conn:
125+
conn.execute(create_webhook_table)
126+
conn.commit()
127+
migrations_applied.append("Created webhook table")
128+
except Exception as e:
129+
print(f"⚠️ Could not create webhook table (may already exist): {e}")
130+
131+
# Migration 3: Update existing user_settings to have webhook_notifications = FALSE if NULL
132+
try:
133+
with db.engine.connect() as conn:
134+
conn.execute(text('UPDATE user_settings SET webhook_notifications = FALSE WHERE webhook_notifications IS NULL'))
135+
conn.commit()
136+
migrations_applied.append("Updated existing user settings with default webhook_notifications value")
137+
except Exception as e:
138+
print(f"⚠️ Could not update existing user settings: {e}")
139+
140+
if migrations_applied:
141+
print("🔄 Database migrations applied:")
142+
for migration in migrations_applied:
143+
print(f" ✅ {migration}")
144+
else:
145+
print("✅ Database schema is up to date")
146+
147+
except Exception as e:
148+
print(f"❌ Database migration failed: {e}")
149+
print("⚠️ The application may not work correctly until database schema is updated")
150+
31151
def create_app():
32152
app = Flask(__name__)
33153
app.config.from_object(Config)
@@ -161,6 +281,9 @@ def inject_user_date_format():
161281
app.register_blueprint(main)
162282

163283
with app.app_context():
284+
# Run automatic database migrations before creating tables
285+
migrate_database()
286+
164287
db.create_all()
165288

166289
# Create default admin user if no admin users exist

app/email.py

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -298,9 +298,9 @@ def check_expiring_subscriptions(app):
298298
for user in users:
299299
user_settings = user.settings or UserSettings()
300300

301-
# Skip if user has disabled email notifications
302-
if not user_settings.email_notifications:
303-
print(f"⏭️ Skipping {user.username} - notifications disabled")
301+
# Skip if user has disabled ALL notifications
302+
if not user_settings.email_notifications and not user_settings.webhook_notifications:
303+
print(f"⏭️ Skipping {user.username} - all notifications disabled")
304304
continue
305305

306306
# Double-check if we already sent a notification today for this user (database level check)
@@ -336,24 +336,57 @@ def check_expiring_subscriptions(app):
336336
expiring_subscriptions.append(subscription)
337337

338338
if expiring_subscriptions:
339-
print(f"📧 Sending notification to {user.username} for {len(expiring_subscriptions)} subscriptions at preferred time {preferred_hour}:00")
339+
# Determine which notification methods are enabled
340+
methods = []
341+
if user_settings.email_notifications:
342+
methods.append("email")
343+
if user_settings.webhook_notifications:
344+
methods.append("webhook")
340345

341-
# Set the notification sent flag BEFORE sending email to prevent race conditions
346+
print(f"� Sending {'/'.join(methods)} notification(s) to {user.username} for {len(expiring_subscriptions)} subscriptions at preferred time {preferred_hour}:00")
347+
348+
# Set the notification sent flag BEFORE sending notifications to prevent race conditions
342349
if not user.settings:
343350
user_settings = UserSettings(user_id=user.id)
344351
db.session.add(user_settings)
345352
user_settings.last_notification_sent = today
346353
db.session.commit()
347354

348-
success = send_expiry_notification(app, user, expiring_subscriptions)
349-
if success:
355+
# Send email notification if enabled
356+
email_success = True
357+
if user_settings.email_notifications:
358+
email_success = send_expiry_notification(app, user, expiring_subscriptions)
359+
if email_success:
360+
print(f"✅ Email notification sent to {user.username}")
361+
else:
362+
print(f"❌ Failed to send email notification to {user.username}")
363+
364+
# Send webhook notifications if enabled
365+
webhook_success = True
366+
if user_settings.webhook_notifications:
367+
try:
368+
from app.webhooks import send_all_webhook_notifications
369+
webhook_count = send_all_webhook_notifications(app, user, expiring_subscriptions)
370+
if webhook_count > 0:
371+
print(f"✅ {webhook_count} webhook notification(s) sent to {user.username}")
372+
else:
373+
print(f"⚠️ No active webhooks configured for {user.username}")
374+
except ImportError as e:
375+
print(f"❌ Webhook module not available: {e}")
376+
webhook_success = False
377+
except Exception as e:
378+
print(f"❌ Failed to send webhook notifications to {user.username}: {e}")
379+
webhook_success = False
380+
381+
# Consider the notification successful if at least one method worked
382+
if email_success or webhook_success:
350383
total_notifications += 1
351384
print(f"✅ Notification successfully sent and marked as sent for {user.username}")
352385
else:
353-
# If email failed, remove the notification flag so it can be retried later
386+
# If both email and webhook failed, remove the notification flag so it can be retried later
354387
user_settings.last_notification_sent = None
355388
db.session.commit()
356-
print(f"❌ Failed to send notification to {user.username}, will retry later")
389+
print(f"❌ All notification methods failed for {user.username}, will retry later")
357390
else:
358391
print(f"✅ No expiring subscriptions for {user.username}")
359392

app/forms.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ def validate_email(self, email):
8686

8787
class NotificationSettingsForm(FlaskForm):
8888
email_notifications = BooleanField('Enable Email Notifications')
89+
webhook_notifications = BooleanField('Enable Webhook Notifications')
8990
notification_days = IntegerField('Days before expiry to send notification',
9091
validators=[DataRequired(), NumberRange(min=1, max=365)])
9192
notification_time = SelectField('Daily notification time',
@@ -148,3 +149,32 @@ class AdminEditUserForm(FlaskForm):
148149
email = StringField('Email', validators=[DataRequired(), Email()])
149150
new_password = PasswordField('New Password (leave blank to keep current)', validators=[Optional(), Length(min=6)])
150151
is_admin = BooleanField('Admin User')
152+
153+
class WebhookForm(FlaskForm):
154+
name = StringField('Webhook Name', validators=[DataRequired(), Length(min=1, max=100)])
155+
webhook_type = SelectField('Webhook Type',
156+
choices=[('gotify', 'Gotify'),
157+
('teams', 'Microsoft Teams'),
158+
('discord', 'Discord'),
159+
('slack', 'Slack'),
160+
('generic', 'Generic JSON')],
161+
validators=[DataRequired()])
162+
url = StringField('Webhook URL', validators=[DataRequired(), Length(min=1, max=500)])
163+
auth_header = StringField('API Key/Token (optional)', validators=[Optional(), Length(max=200)])
164+
auth_username = StringField('Username (for Basic Auth)', validators=[Optional(), Length(max=100)])
165+
auth_password = PasswordField('Password (for Basic Auth)', validators=[Optional(), Length(max=200)])
166+
custom_headers = TextAreaField('Custom Headers (JSON format)', validators=[Optional()],
167+
render_kw={'placeholder': '{"X-Custom-Header": "value", "Another-Header": "value2"}'})
168+
is_active = BooleanField('Active', default=True)
169+
170+
def validate_custom_headers(self, field):
171+
if field.data:
172+
try:
173+
import json
174+
json.loads(field.data)
175+
except ValueError:
176+
raise ValidationError('Custom headers must be valid JSON format')
177+
178+
def validate_url(self, field):
179+
if not (field.data.startswith('http://') or field.data.startswith('https://')):
180+
raise ValidationError('URL must start with http:// or https://')

app/models.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class UserSettings(db.Model):
2727

2828
# Notification settings
2929
email_notifications = db.Column(db.Boolean, default=True)
30+
webhook_notifications = db.Column(db.Boolean, default=False)
3031
notification_days = db.Column(db.Integer, default=7)
3132
notification_time = db.Column(db.Integer, default=9) # Hour of day (0-23) when to send notifications
3233
last_notification_sent = db.Column(db.Date) # Track when last daily summary was sent
@@ -50,6 +51,55 @@ class UserSettings(db.Model):
5051
def __repr__(self):
5152
return f'<UserSettings {self.user_id}>'
5253

54+
class Webhook(db.Model):
55+
id = db.Column(db.Integer, primary_key=True)
56+
name = db.Column(db.String(100), nullable=False)
57+
webhook_type = db.Column(db.String(50), nullable=False) # 'gotify', 'teams', 'discord', 'slack', 'generic'
58+
url = db.Column(db.String(500), nullable=False)
59+
auth_header = db.Column(db.String(200)) # For API keys, tokens
60+
auth_username = db.Column(db.String(100)) # For basic auth
61+
auth_password = db.Column(db.String(200)) # For basic auth (hashed)
62+
custom_headers = db.Column(db.Text) # JSON string for custom headers
63+
is_active = db.Column(db.Boolean, default=True)
64+
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
65+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
66+
last_used = db.Column(db.DateTime)
67+
68+
# Relationship
69+
user = db.relationship('User', backref=db.backref('webhooks', lazy=True, cascade='all, delete-orphan'))
70+
71+
def __repr__(self):
72+
return f'<Webhook {self.name} ({self.webhook_type})>'
73+
74+
def get_auth_headers(self):
75+
"""Get authentication headers for the webhook"""
76+
headers = {'Content-Type': 'application/json'}
77+
78+
# Add custom headers if any
79+
if self.custom_headers:
80+
try:
81+
import json
82+
custom = json.loads(self.custom_headers)
83+
headers.update(custom)
84+
except (ValueError, TypeError):
85+
pass
86+
87+
# Add authentication
88+
if self.auth_header:
89+
# Check if it's a bearer token format
90+
if self.auth_header.startswith('Bearer '):
91+
headers['Authorization'] = self.auth_header
92+
elif self.webhook_type == 'gotify':
93+
headers['X-Gotify-Key'] = self.auth_header
94+
else:
95+
headers['Authorization'] = f'Bearer {self.auth_header}'
96+
elif self.auth_username and self.auth_password:
97+
import base64
98+
credentials = base64.b64encode(f'{self.auth_username}:{self.auth_password}'.encode()).decode()
99+
headers['Authorization'] = f'Basic {credentials}'
100+
101+
return headers
102+
53103
class ExchangeRate(db.Model):
54104
id = db.Column(db.Integer, primary_key=True)
55105
date = db.Column(db.Date, nullable=False)

0 commit comments

Comments
 (0)