Coverage for src/backend/InvenTree/InvenTree/backends.py: 46%
87 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
1"""Custom backend implementation for maintenance-mode."""
3import datetime
4import time
6from django.conf import settings
7from django.core.exceptions import ValidationError
8from django.core.mail.backends.base import BaseEmailBackend
9from django.core.mail.backends.locmem import EmailBackend as LocMemEmailBackend
10from django.core.mail.message import EmailMessage, EmailMultiAlternatives
11from django.utils.module_loading import import_string
13import structlog
14from maintenance_mode.backends import AbstractStateBackend
16import common.models
17from common.settings import get_global_setting, set_global_setting
19logger = structlog.get_logger('inventree')
22class InvenTreeMaintenanceModeBackend(AbstractStateBackend):
23 """Custom backend for managing state of maintenance mode.
25 Stores a timestamp in the database to determine when maintenance mode will elapse.
26 """
28 SETTING_KEY = '_MAINTENANCE_MODE'
30 def get_value(self) -> bool:
31 """Get the current state of the maintenance mode.
33 Returns:
34 bool: True if maintenance mode is active, False otherwise.
35 """
36 try:
37 value = get_global_setting(self.SETTING_KEY)
38 except Exception:
39 # Database is inaccessible - assume we are not in maintenance mode
40 logger.debug('Failed to read maintenance mode state - assuming False')
41 return False
43 # Extract timestamp from string
44 try:
45 # If the timestamp is in the past, we are now *out* of maintenance mode
46 timestamp = datetime.datetime.fromisoformat(value)
47 return timestamp > datetime.datetime.now()
48 except ValueError:
49 # If the value is not a valid timestamp, assume maintenance mode is not active
50 return False
52 def set_value(self, value: bool, retries: int = 5, minutes: int = 5):
53 """Set the state of the maintenance mode.
55 Instead of simply writing "true" or "false" to the setting,
56 we write a timestamp to the setting, which is used to determine
57 when maintenance mode will elapse.
58 This ensures that we will always *exit* maintenance mode after a certain time period.
59 """
60 logger.debug('Setting maintenance mode state: %s', value)
62 if value:
63 # Save as isoformat
64 timestamp = datetime.datetime.now() + datetime.timedelta(minutes=minutes)
65 timestamp = timestamp.isoformat()
66 else:
67 # Blank timestamp means maintenance mode is not active
68 timestamp = ''
70 r = retries
72 while r > 0:
73 try:
74 set_global_setting(self.SETTING_KEY, timestamp)
75 # Read the value back to confirm
76 if self.get_value() == value:
77 break
78 except Exception:
79 # In the database is locked, then
80 logger.debug(
81 'Failed to set maintenance mode state (%s retries left)', r
82 )
83 time.sleep(0.1)
85 r -= 1
87 # Disable this warning message (for now) as it is confusing users with no upside
88 # if r == 0:
89 # logger.warning(
90 # 'Failed to set maintenance mode state after %s retries', retries
91 # )
94class InvenTreeMailLoggingBackend(BaseEmailBackend):
95 """Backend that logs send mails to the database."""
97 def __init__(self, *args, **kwargs):
98 """Initialize the email backend."""
99 super().__init__(*args, **kwargs)
100 klass = import_string(settings.INTERNAL_EMAIL_BACKEND)
101 self.backend: BaseEmailBackend = klass(*args, **kwargs)
103 def open(self):
104 """Open the email backend connection."""
105 return self.backend.open()
107 def close(self):
108 """Close the email backend connection."""
109 return self.backend.open()
111 def send_messages(
112 self, email_messages: list[EmailMessage | EmailMultiAlternatives]
113 ) -> int:
114 """Send email messages and log them to the database.
116 Args:
117 email_messages (list): List of EmailMessage objects to send.
118 """
119 from plugin.base.mail.mail import process_mail_out
121 # Issue mails to plugins
122 process_mail_out(email_messages)
124 # Process
125 msg_ids: list[common.models.EmailMessage] = []
126 try:
127 msg_ids = common.models.log_email_messages(email_messages)
128 except Exception: # pragma: no cover
129 logger.exception('INVE-W10: Problem logging recipients, ignoring')
131 # Anymail: pre-processing
132 if settings.INTERNAL_EMAIL_BACKEND.startswith('anymail.backends.'):
133 for a in email_messages:
134 if a.extra_headers and common.models.HEADER_MSG_ID in a.extra_headers:
135 # Remove the Message-ID header from the email
136 # This is because some ESPs do not like it being set
137 # in the headers, and will ignore the email
138 a.extra_headers.pop(common.models.HEADER_MSG_ID)
139 # Add tracking if requested: TODO
140 # a.track_opens = True
142 # Send
143 try:
144 ret_val = self.backend.send_messages(email_messages)
145 except Exception as e:
146 logger.exception('INVE-W10: Problem sending email: %s', e)
147 # If we fail to send the email, we need to set the status to ERROR
148 for msg in msg_ids:
149 msg.status = common.models.EmailMessage.EmailStatus.FAILED
150 msg.error_message = str(e)
151 msg.save()
152 raise ValidationError(f'INVE-W10: Failed to send email: {e}') from e
154 # Log
155 if ret_val == 0: # pragma: no cover
156 logger.info('INVE-W10: No emails sent')
157 else:
158 logger.info('INVE-W10: %s emails sent', ret_val)
159 if settings.INTERNAL_EMAIL_BACKEND.startswith('anymail.backends.'):
160 # Anymail: ESP does return the message ID for us so we need to set it in the database
161 for k, v in {
162 a.extra_headers['X-InvenTree-MsgId-1']: a.anymail_status.message_id
163 for a in email_messages
164 }.items():
165 current = common.models.EmailMessage.objects.get(global_id=k)
166 current.message_id_key = v
167 current.status = common.models.EmailMessage.EmailStatus.SENT
168 current.save()
169 else:
170 common.models.EmailMessage.objects.filter(
171 pk__in=[msg.pk for msg in msg_ids]
172 ).update(status=common.models.EmailMessage.EmailStatus.SENT)
173 return ret_val
176class InvenTreeErrorMailBackend(LocMemEmailBackend):
177 """Backend that generates an error when sending - for testing."""
179 def send_messages(self, email_messages):
180 """Issues an error when sending email messages."""
181 super().send_messages(email_messages)
182 # Simulate an error
183 raise ValueError('Test error sending email')