stash
This commit is contained in:
parent
a4d79896da
commit
5ddd8c0b4b
15 changed files with 180 additions and 78 deletions
|
@ -61,6 +61,7 @@ INSTALLED_APPS = [
|
||||||
'drf_yasg',
|
'drf_yasg',
|
||||||
'channels',
|
'channels',
|
||||||
'authentication',
|
'authentication',
|
||||||
|
'notifications',
|
||||||
'files',
|
'files',
|
||||||
'tickets',
|
'tickets',
|
||||||
'inventory',
|
'inventory',
|
||||||
|
|
|
@ -1,28 +0,0 @@
|
||||||
# Generated by Django 4.2.7 on 2024-04-26 13:08
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
('mail', '0004_alter_emailattachment_file'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='UserNotificationChannel',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('channel_type', models.CharField(choices=[('telegram', 'telegram'), ('email', 'email')], max_length=255)),
|
|
||||||
('channel_target', models.CharField(max_length=255)),
|
|
||||||
('event_filter', models.CharField(max_length=255)),
|
|
||||||
('active', models.BooleanField(default=True)),
|
|
||||||
('created', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -41,13 +41,4 @@ class EmailAttachment(AbstractFile):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
|
|
||||||
|
|
||||||
class UserNotificationChannel(models.Model):
|
|
||||||
user = models.ForeignKey(ExtendedUser, models.CASCADE)
|
|
||||||
channel_type = models.CharField(choices=[('telegram', 'telegram'), ('email', 'email')], max_length=255)
|
|
||||||
channel_target = models.CharField(max_length=255)
|
|
||||||
event_filter = models.CharField(max_length=255)
|
|
||||||
active = models.BooleanField(default=True)
|
|
||||||
created = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
def validate_constraints(self, exclude=None): # TODO: email -> emailaddress, telegram -> chatid
|
|
||||||
return True
|
|
||||||
|
|
|
@ -5,8 +5,8 @@ from channels.layers import get_channel_layer
|
||||||
from channels.db import database_sync_to_async
|
from channels.db import database_sync_to_async
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
from core.settings import PRIMARY_HOST
|
|
||||||
from mail.models import Email, EventAddress, EmailAttachment
|
from mail.models import Email, EventAddress, EmailAttachment
|
||||||
|
from notifications.templates import render_auto_reply
|
||||||
from notify_sessions.models import SystemEvent
|
from notify_sessions.models import SystemEvent
|
||||||
from tickets.models import IssueThread
|
from tickets.models import IssueThread
|
||||||
|
|
||||||
|
@ -83,14 +83,13 @@ def make_reply(reply_email, references=None, event=None):
|
||||||
return reply
|
return reply
|
||||||
|
|
||||||
|
|
||||||
def make_notification(message, to, event=None): # TODO where should replies to this go
|
def make_notification(message, to, title): # TODO where should replies to this go
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from core.settings import MAIL_DOMAIN
|
from core.settings import MAIL_DOMAIN
|
||||||
event = event or "mail"
|
|
||||||
notification = EmailMessage()
|
notification = EmailMessage()
|
||||||
notification["From"] = "notifications@%s" % MAIL_DOMAIN
|
notification["From"] = "notifications@%s" % MAIL_DOMAIN
|
||||||
notification["To"] = to
|
notification["To"] = to
|
||||||
notification["Subject"] = f"System3 Notification"
|
notification["Subject"] = f"[C3LF Notification]%s" % title
|
||||||
# notification["Reply-To"] = f"{event}@{MAIL_DOMAIN}"
|
# notification["Reply-To"] = f"{event}@{MAIL_DOMAIN}"
|
||||||
# notification["In-Reply-To"] = email.reference
|
# notification["In-Reply-To"] = email.reference
|
||||||
# notification["Message-ID"] = email.id + "@" + MAIL_DOMAIN
|
# notification["Message-ID"] = email.id + "@" + MAIL_DOMAIN
|
||||||
|
@ -198,11 +197,11 @@ def receive_email(envelope, log=None):
|
||||||
header_in_reply_to = parsed.get('In-Reply-To')
|
header_in_reply_to = parsed.get('In-Reply-To')
|
||||||
header_message_id = parsed.get('Message-ID')
|
header_message_id = parsed.get('Message-ID')
|
||||||
|
|
||||||
#if header_from != envelope.mail_from:
|
# if header_from != envelope.mail_from:
|
||||||
# log.warning("Header from does not match envelope from")
|
# log.warning("Header from does not match envelope from")
|
||||||
# log.info(f"Header from: {header_from}, envelope from: {envelope.mail_from}")
|
# log.info(f"Header from: {header_from}, envelope from: {envelope.mail_from}")
|
||||||
#
|
#
|
||||||
#if header_to != envelope.rcpt_tos[0]:
|
# if header_to != envelope.rcpt_tos[0]:
|
||||||
# log.warning("Header to does not match envelope to")
|
# log.warning("Header to does not match envelope to")
|
||||||
# log.info(f"Header to: {header_to}, envelope to: {envelope.rcpt_tos[0]}")
|
# log.info(f"Header to: {header_to}, envelope to: {envelope.rcpt_tos[0]}")
|
||||||
|
|
||||||
|
@ -231,16 +230,7 @@ def receive_email(envelope, log=None):
|
||||||
references = collect_references(active_issue_thread)
|
references = collect_references(active_issue_thread)
|
||||||
if not sender.startswith('noreply'):
|
if not sender.startswith('noreply'):
|
||||||
subject = f"Re: {subject} [#{active_issue_thread.short_uuid()}]"
|
subject = f"Re: {subject} [#{active_issue_thread.short_uuid()}]"
|
||||||
body = '''Your request (#{}) has been received and will be reviewed by our lost&found angels.
|
body = render_auto_reply(active_issue_thread)
|
||||||
|
|
||||||
We are reviewing incoming requests during the event and teardown. Immediately after the event, expect a delay as the \
|
|
||||||
workload is high. We will not forget about your request and get back in touch once we have updated information on your \
|
|
||||||
request. Requests for devices, wallets, credit cards or similar items will be handled with priority.
|
|
||||||
|
|
||||||
If you happen to find your lost item or just want to add additional information, please reply to this email. Please \
|
|
||||||
do not create a new request.
|
|
||||||
|
|
||||||
Your c3lf (Cloakroom + Lost&Found) Team'''.format(active_issue_thread.short_uuid())
|
|
||||||
reply_email = Email.objects.create(
|
reply_email = Email.objects.create(
|
||||||
sender=recipient, recipient=sender, body=body, subject=subject,
|
sender=recipient, recipient=sender, body=body, subject=subject,
|
||||||
in_reply_to=header_message_id, event=target_event, issue_thread=active_issue_thread)
|
in_reply_to=header_message_id, event=target_event, issue_thread=active_issue_thread)
|
||||||
|
@ -251,20 +241,7 @@ Your c3lf (Cloakroom + Lost&Found) Team'''.format(active_issue_thread.short_uuid
|
||||||
active_issue_thread.state = 'pending_open'
|
active_issue_thread.state = 'pending_open'
|
||||||
active_issue_thread.save()
|
active_issue_thread.save()
|
||||||
|
|
||||||
notification = None
|
return email, new, reply, active_issue_thread
|
||||||
if len(active_issue_thread.name) > 50:
|
|
||||||
notify_subject = active_issue_thread.name[:47] + "..."
|
|
||||||
else:
|
|
||||||
notify_subject = active_issue_thread.name
|
|
||||||
eventslug = target_event.slug if target_event else "37C3" # TODO 37C3 should not be hardcoded
|
|
||||||
if new:
|
|
||||||
notification = f"""New issue \"{active_issue_thread.name}\" [{active_issue_thread.short_uuid()}] created
|
|
||||||
https://{PRIMARY_HOST}/{eventslug}/ticket/{active_issue_thread.id}/"""
|
|
||||||
else:
|
|
||||||
notification = f"""Reply to issue \"{active_issue_thread.name}\" [{active_issue_thread.short_uuid()}]
|
|
||||||
https://{PRIMARY_HOST}/{eventslug}/ticket/{active_issue_thread.id}/"""
|
|
||||||
|
|
||||||
return email, new, reply, notification
|
|
||||||
|
|
||||||
|
|
||||||
class LMTPHandler:
|
class LMTPHandler:
|
||||||
|
@ -286,7 +263,7 @@ class LMTPHandler:
|
||||||
content = None
|
content = None
|
||||||
try:
|
try:
|
||||||
content = envelope.content
|
content = envelope.content
|
||||||
email, new, reply, notification = await database_sync_to_async(receive_email)(envelope, log)
|
email, new, reply, thread = await database_sync_to_async(receive_email)(envelope, log)
|
||||||
log.info(f"Created email {email.id}")
|
log.info(f"Created email {email.id}")
|
||||||
systemevent = await database_sync_to_async(SystemEvent.objects.create)(type='email received',
|
systemevent = await database_sync_to_async(SystemEvent.objects.create)(type='email received',
|
||||||
reference=email.id)
|
reference=email.id)
|
||||||
|
@ -296,11 +273,10 @@ class LMTPHandler:
|
||||||
'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id,
|
'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id,
|
||||||
"message": "email received"})
|
"message": "email received"})
|
||||||
log.info(f"Sent message to frontend")
|
log.info(f"Sent message to frontend")
|
||||||
if notification:
|
if thread:
|
||||||
log.info(f"Sending notification {notification}")
|
|
||||||
await channel_layer.group_send(
|
await channel_layer.group_send(
|
||||||
'general', {"type": "generic.event", "name": "user_notification", "event_id": systemevent.id,
|
'general', {"type": "generic.event", "name": "user_notification", "event_id": systemevent.id,
|
||||||
"message": notification})
|
"ticket": thread, "new": new})
|
||||||
if new and reply:
|
if new and reply:
|
||||||
log.info('Sending message to %s' % reply['To'])
|
log.info('Sending message to %s' % reply['To'])
|
||||||
await send_smtp(reply)
|
await send_smtp(reply)
|
||||||
|
|
|
@ -2,7 +2,7 @@ from django.contrib.auth.models import Permission
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from authentication.models import ExtendedUser
|
from authentication.models import ExtendedUser
|
||||||
from mail.models import UserNotificationChannel
|
from notifications.models import UserNotificationChannel
|
||||||
|
|
||||||
|
|
||||||
class UserNotificationTestCase(TestCase):
|
class UserNotificationTestCase(TestCase):
|
||||||
|
|
0
core/notifications/__init__.py
Normal file
0
core/notifications/__init__.py
Normal file
16
core/notifications/defauls.py
Normal file
16
core/notifications/defauls.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
auto_reply_body = '''Your request (#{{ ticket_uuid }}) has been received and will be reviewed by our lost&found angels.
|
||||||
|
|
||||||
|
We are reviewing incoming requests during the event and teardown. Immediately after the event, expect a delay as the \
|
||||||
|
workload is high. We will not forget about your request and get back in touch once we have updated information on your \
|
||||||
|
request. Requests for devices, wallets, credit cards or similar items will be handled with priority.
|
||||||
|
|
||||||
|
If you happen to find your lost item or just want to add additional information, please reply to this email. Please \
|
||||||
|
do not create a new request.
|
||||||
|
|
||||||
|
Your c3lf (Cloakroom + Lost&Found) Team'''
|
||||||
|
|
||||||
|
new_issue_notification = '''New issue "{{ ticket_name | limit_length }}" [{{ ticket_uuid }}] created
|
||||||
|
{{ ticket_url }}'''
|
||||||
|
|
||||||
|
reply_issue_notification = '''Reply to issue "{{ ticket_name }}" [{{ ticket_uuid }}]
|
||||||
|
{{ ticket_url }}'''
|
|
@ -4,8 +4,9 @@ from channels.db import database_sync_to_async
|
||||||
from urllib.parse import quote as urlencode
|
from urllib.parse import quote as urlencode
|
||||||
|
|
||||||
from core.settings import TELEGRAM_BOT_TOKEN, TELEGRAM_GROUP_CHAT_ID
|
from core.settings import TELEGRAM_BOT_TOKEN, TELEGRAM_GROUP_CHAT_ID
|
||||||
from mail.models import UserNotificationChannel
|
|
||||||
from mail.protocol import send_smtp, make_notification
|
from mail.protocol import send_smtp, make_notification
|
||||||
|
from notifications.models import UserNotificationChannel
|
||||||
|
from notifications.templates import render_notification_new_ticket, render_notification_reply_ticket
|
||||||
|
|
||||||
|
|
||||||
async def http_get(url):
|
async def http_get(url):
|
||||||
|
@ -51,12 +52,13 @@ class NotificationDispatcher:
|
||||||
|
|
||||||
if (message and 'type' in message and message['type'] == 'generic.event' and 'name' in message and
|
if (message and 'type' in message and message['type'] == 'generic.event' and 'name' in message and
|
||||||
message['name'] == 'user_notification'):
|
message['name'] == 'user_notification'):
|
||||||
if 'message' in message and 'event_id' in message:
|
if 'ticket' in message and 'event_id' in message and 'new' in message:
|
||||||
await self.dispatch(message['message'], message['event_id'])
|
await self.dispatch(message['ticket'], message['event_id'], message['new'])
|
||||||
else:
|
else:
|
||||||
print("Error: Invalid message format")
|
print("Error: Invalid message format")
|
||||||
|
|
||||||
async def dispatch(self, message, event_id):
|
async def dispatch(self, ticket, event_id, new):
|
||||||
|
message = render_notification_new_ticket(ticket) if new else render_notification_reply_ticket(ticket)
|
||||||
print("Dispatching message:", message, "with event_id:", event_id)
|
print("Dispatching message:", message, "with event_id:", event_id)
|
||||||
targets = await self.get_notification_targets()
|
targets = await self.get_notification_targets()
|
||||||
await telegram_notify(message, TELEGRAM_GROUP_CHAT_ID)
|
await telegram_notify(message, TELEGRAM_GROUP_CHAT_ID)
|
48
core/notifications/migrations/0001_initial.py
Normal file
48
core/notifications/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
# Generated by Django 4.2.7 on 2024-05-03 21:02
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
from notifications.defauls import auto_reply_body, new_issue_notification, reply_issue_notification
|
||||||
|
from notifications.models import MessageTemplate
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
def create_required_templates(apps, schema_editor):
|
||||||
|
MessageTemplate.objects.create(name='auto_reply', message=auto_reply_body)
|
||||||
|
MessageTemplate.objects.create(name='new_issue_notification', message=new_issue_notification)
|
||||||
|
MessageTemplate.objects.create(name='reply_issue_notification', message=reply_issue_notification)
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MessageTemplate',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=255)),
|
||||||
|
('message', models.TextField()),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('marked_confidential', models.BooleanField(default=False)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserNotificationChannel',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('channel_type',
|
||||||
|
models.CharField(choices=[('telegram', 'telegram'), ('email', 'email')], max_length=255)),
|
||||||
|
('channel_target', models.CharField(max_length=255)),
|
||||||
|
('event_filter', models.CharField(max_length=255)),
|
||||||
|
('active', models.BooleanField(default=True)),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.RunPython(create_required_templates),
|
||||||
|
]
|
0
core/notifications/migrations/__init__.py
Normal file
0
core/notifications/migrations/__init__.py
Normal file
22
core/notifications/models.py
Normal file
22
core/notifications/models.py
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from authentication.models import ExtendedUser
|
||||||
|
|
||||||
|
|
||||||
|
class MessageTemplate(models.Model):
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
message = models.TextField()
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
marked_confidential = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
|
||||||
|
class UserNotificationChannel(models.Model):
|
||||||
|
user = models.ForeignKey(ExtendedUser, models.CASCADE)
|
||||||
|
channel_type = models.CharField(choices=[('telegram', 'telegram'), ('email', 'email')], max_length=255)
|
||||||
|
channel_target = models.CharField(max_length=255)
|
||||||
|
event_filter = models.CharField(max_length=255)
|
||||||
|
active = models.BooleanField(default=True)
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def validate_constraints(self, exclude=None): # TODO: email -> emailaddress, telegram -> chatid
|
||||||
|
return True
|
54
core/notifications/templates.py
Normal file
54
core/notifications/templates.py
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import jinja2
|
||||||
|
from core.settings import PRIMARY_HOST
|
||||||
|
|
||||||
|
from notifications.models import MessageTemplate
|
||||||
|
|
||||||
|
# auto_reply_title = f"Re: {{ ticket_name }} [#{{ ticket_uuid }}]"
|
||||||
|
|
||||||
|
|
||||||
|
TEMLATE_VARS = ['ticket_name', 'ticket_uuid', 'ticket_id', 'ticket_url',
|
||||||
|
'event_slug', 'event_name',
|
||||||
|
'username', 'user_nick',
|
||||||
|
'web_host'] # TODO customer_name, tracking_code
|
||||||
|
|
||||||
|
|
||||||
|
def limit_length(s, length=50):
|
||||||
|
if len(s) > length:
|
||||||
|
return s[:(length - 3)] + "..."
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def ticket_url(ticket):
|
||||||
|
eventslug = ticket.event.slug if ticket.event else "37C3" # TODO 37C3 should not be hardcoded
|
||||||
|
return f"https://{PRIMARY_HOST}/{eventslug}/ticket/{ticket.id}/"
|
||||||
|
|
||||||
|
|
||||||
|
def render_template(template, **kwargs):
|
||||||
|
try:
|
||||||
|
|
||||||
|
environment = jinja2.Environment()
|
||||||
|
environment.filters['limit_length'] = limit_length
|
||||||
|
|
||||||
|
tmpl = MessageTemplate.objects.get(name=template)
|
||||||
|
template = environment.from_string(tmpl.message)
|
||||||
|
return template.render(**kwargs, web_host=PRIMARY_HOST)
|
||||||
|
except MessageTemplate.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def render_auto_reply(ticket):
|
||||||
|
eventslug = ticket.event.slug if ticket.event else "37C3" # TODO 37C3 should not be hardcoded
|
||||||
|
return render_template('auto_reply', ticket_name=ticket.name, ticket_uuid=ticket.short_uuid(),
|
||||||
|
ticket_id=ticket.id, event_slug=eventslug, ticket_url=ticket_url(ticket))
|
||||||
|
|
||||||
|
|
||||||
|
def render_notification_new_ticket(ticket):
|
||||||
|
eventslug = ticket.event.slug if ticket.event else "37C3" # TODO 37C3 should not be hardcoded
|
||||||
|
return render_template('new_issue_notification', ticket_name=ticket.name, ticket_uuid=ticket.short_uuid(),
|
||||||
|
ticket_id=ticket.id, event_slug=eventslug, ticket_url=ticket_url(ticket))
|
||||||
|
|
||||||
|
|
||||||
|
def render_notification_reply_ticket(ticket):
|
||||||
|
eventslug = ticket.event.slug if ticket.event else "37C3" # TODO 37C3 should not be hardcoded
|
||||||
|
return render_template('reply_issue_notification', ticket_name=ticket.name, ticket_uuid=ticket.short_uuid(),
|
||||||
|
ticket_id=ticket.id, event_slug=eventslug, ticket_url=ticket_url(ticket))
|
0
core/notifications/tests/__init__.py
Normal file
0
core/notifications/tests/__init__.py
Normal file
2
core/server.py
Normal file → Executable file
2
core/server.py
Normal file → Executable file
|
@ -12,7 +12,7 @@ django.setup()
|
||||||
from helper import init_loop
|
from helper import init_loop
|
||||||
from mail.protocol import LMTPHandler
|
from mail.protocol import LMTPHandler
|
||||||
from mail.socket import UnixSocketLMTPController
|
from mail.socket import UnixSocketLMTPController
|
||||||
from mail.notifications import NotificationDispatcher
|
from notifications.dispatch import NotificationDispatcher
|
||||||
|
|
||||||
|
|
||||||
class UvicornServer(uvicorn.Server):
|
class UvicornServer(uvicorn.Server):
|
||||||
|
|
20
core/tickets/migrations/0009_issuethread_event.py
Normal file
20
core/tickets/migrations/0009_issuethread_event.py
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
# Generated by Django 4.2.7 on 2024-05-03 21:21
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('inventory', '0004_alter_event_created_at_alter_item_created_at'),
|
||||||
|
('tickets', '0008_alter_issuethread_options_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='issuethread',
|
||||||
|
name='event',
|
||||||
|
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_threads', to='inventory.event'),
|
||||||
|
),
|
||||||
|
]
|
Loading…
Reference in a new issue