From b9fd273671c3624ae2e312a509d0a7b5cb4334ad Mon Sep 17 00:00:00 2001 From: Vijay Sarvepalli Date: Fri, 15 Dec 2023 11:54:31 -0500 Subject: [PATCH] Updates to VINCE 2.1.9 --- CHANGELOG.md | 18 + bigvince/settings_.py | 2 +- requirements.txt | 4 +- vince/lib.py | 56 ++- vince/mailer.py | 20 ++ vince/models.py | 23 ++ vince/static/vince/css/style.css | 2 +- vince/static/vince/js/case.js | 143 +++++--- vince/static/vince/js/vince.js | 100 ++++++ vince/templates/vince/base.html | 324 ++++++++++++++++++ vince/templates/vince/case.html | 17 +- vince/templates/vince/case_summary.html | 4 +- .../vince/include/case_activity.html | 16 +- vince/templates/vince/manage_alerts.html | 47 +++ vince/templates/vince/printweeklyreport.html | 29 ++ vince/urls.py | 4 +- vince/views.py | 201 +++++++---- vinceworker/views.py | 12 +- vinny/templates/vinny/base.html | 5 +- vinny/templates/vinny/base_public.html | 5 +- vinny/views.py | 9 +- 21 files changed, 893 insertions(+), 148 deletions(-) create mode 100644 vince/templates/vince/base.html create mode 100644 vince/templates/vince/manage_alerts.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f1fcf1..b03b3e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # VINCE Changelog + +Version 2.1.9 2023-12-07 + +* Dependabot update recommendations: `cryptography` 41.0.3 to 41.0.6 +* Fixed bug that prevented "Add Vulnerability" button from rerouting user to appropriate pages upon submission +* Integrated custom metrics into weekly reports on VINCE activity + +Version 2.1.8 2023-11-08 + +* Dependabot update recommendations: `django` 3.2.20 to 3.2.23 +* Restructured vendors tab on VINCE Track case page so that vendors table is paginated rather than indefinitely scrollable + +Version 2.1.7 2023-10-30 + +* Added customization of MFA +* Added code to catch and correct Vul Note Reviews with data omissions that led to page load failures in certain circumstances + + Version 2.1.6 2023-10-25 * Fixed bug that interfered in certain circumstances with the operation of the vendor filter button on the VINCEComm case page diff --git a/bigvince/settings_.py b/bigvince/settings_.py index 5d5478c..3014ae5 100644 --- a/bigvince/settings_.py +++ b/bigvince/settings_.py @@ -56,7 +56,7 @@ ROOT_DIR = environ.Path(__file__) - 3 # any change that requires database migrations is a minor release -VERSION = "2.1.6" +VERSION = "2.1.9" # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/ diff --git a/requirements.txt b/requirements.txt index c66be40..071974b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,11 +19,11 @@ chardet==5.0.0 charset-normalizer==2.1.1 click==8.1.3 colorama==0.4.4 -cryptography==41.0.3 +cryptography==41.0.6 cvelib==1.1.0 Deprecated==1.2.13 dictdiffer==0.9.0 -Django==3.2.20 +Django==3.2.23 django-appconf==1.0.5 django-countries==7.4.2 django-environ==0.9.0 diff --git a/vince/lib.py b/vince/lib.py index 2379685..918da7b 100644 --- a/vince/lib.py +++ b/vince/lib.py @@ -50,7 +50,7 @@ # from vince.models import Attachment, EmailTemplate, ArtifactAttachment, TicketArtifact from vince.models import * from vinny.models import Message, Case, Post, PostRevision, VinceCommContact, GroupContact, CaseMember, CaseMemberStatus, CaseStatement, CaseVulnerability, VTCaseRequest, VinceCommCaseAttachment, ReportAttachment, VinceCommInvitedUsers, CRFollowUp, VCVUReport, VendorAction, VendorStatusChange, CaseCoordinator, ContactInfoChange, CaseViewed, CaseVulExploit, CaseVulCVSS, CoordinatorSettings, VINCEEmailNotification -from vince.mailer import send_newticket_mail, send_daily_digest_mail, send_reset_mfa_email, get_mail_content, send_weekly_report_mail +from vince.mailer import send_newticket_mail, send_daily_digest_mail, send_reset_mfa_email, get_mail_content, send_weekly_report_mail, send_alert_email from .permissions import * import email import email.header @@ -63,6 +63,7 @@ logger.setLevel(logging.DEBUG) from vince.settings import VINCE_ASSIGN_TRIAGE, VINCE_IGNORE_TRANSIENT_BOUNCES from vince.permissions import get_case_case_queue, get_user_case_queue, get_user_gen_queue +from lib.vince.utils import deepGet def md5_file(f): hash_md5 = hashlib.md5() @@ -2681,14 +2682,12 @@ def publish_vul_note(vu_dict, key): def prepare_and_send_weekly_report(): # get time info - context = {} + oneweekago = date.today() - timedelta(days=7) year = oneweekago.isocalendar()[0] week = oneweekago.isocalendar()[1] weekstartdate = date.fromisocalendar(year, week, 1) - context['weekstartdate'] = weekstartdate weekenddate = date.fromisocalendar(year, week, 7) - context['weekenddate'] = weekenddate daterangeend = weekenddate + timedelta(days=1) # examine the GroupSettings model, looking for groups that have weekly="on" @@ -2699,6 +2698,15 @@ def prepare_and_send_weekly_report(): recipients = [] groupid = 0 for groupplussettings in groupsplussettings: + context = {} + context['weekstartdate'] = weekstartdate + context['weekenddate'] = weekenddate + + # This is just for testing: + # weekstartdate = date.today() + # daterangeend = weekstartdate + timedelta(days=1) + # context['weekstartdate'] = weekstartdate + # context['weekenddate'] = weekstartdate + timedelta(days=1) # get recipients data as a list recipients = groupplussettings.metadata["reports"]["recipients"].split(',') @@ -2726,6 +2734,38 @@ def prepare_and_send_weekly_report(): 'active_cases': active_cases, 'deactive_cases': deactive_cases, 'to_active_cases': to_active_cases}}) + if groupid == 1: + ai_ml_boolean = False + # looks like this works: + context['total_ai_ml_crs'] = CaseRequest.objects.annotate(n=Cast(F("metadata__ai_ml_system"),models.TextField())).filter(n__icontains="True", queue__in=my_queues, created__range=[weekstartdate, daterangeend]).count() + if context['total_ai_ml_crs'] > 0: + ai_ml_boolean = True + ai_ml_new_cases = 0 + ai_ml_active_cases = 0 + ai_ml_deactive_cases = 0 + ai_ml_to_active_cases = 0 + for case in new_cases: + if deepGet(case,'case_request.caserequest.metadata.ai_ml_system') == True: + ai_ml_new_cases += 1 + ai_ml_boolean = True + for case in active_cases: + if deepGet(case,'case_request.caserequest.metadata.ai_ml_system') == True: + ai_ml_active_cases += 1 + ai_ml_boolean = True + for case in deactive_cases: + if deepGet(case.case,'case_request.caserequest.metadata.ai_ml_system') == True: + ai_ml_deactive_cases += 1 + ai_ml_boolean = True + for case in to_active_cases: + if deepGet(case.case,'case_request.caserequest.metadata.ai_ml_system') == True: + ai_ml_to_active_cases += 1 + ai_ml_boolean = True + context.update({'ai_ml_case_stats': {'ai_ml_new_cases':ai_ml_new_cases, + 'ai_ml_active_cases': ai_ml_active_cases, + 'ai_ml_deactive_cases': ai_ml_deactive_cases, + 'ai_ml_to_active_cases': ai_ml_to_active_cases}}) + context['total_ai_ml_activity'] = context['total_ai_ml_crs'] + ai_ml_new_cases + context['ai_ml_boolean'] = ai_ml_boolean context['new_users'] = User.objects.using('vincecomm').filter(date_joined__range=[weekstartdate, daterangeend]).count() context['total_users'] = User.objects.using('vincecomm').all().count() vendor_group_dict = {group.name:group.user_set.count() for group in Group.objects.using('vincecomm').exclude(groupcontact__isnull=True) if group.user_set.count() > 0} @@ -2733,6 +2773,7 @@ def prepare_and_send_weekly_report(): vendor_groups = Group.objects.using('vincecomm').exclude(groupcontact__isnull=True) context['vendor_users'] = User.objects.using('vincecomm').filter(groups__in=vendor_groups).distinct().count() context['fwd_reports'] = FollowUp.objects.filter(title__icontains="Successfully forwarded", date__range=[weekstartdate, daterangeend], ticket__queue__in=my_queues) + logger.debug(f'the context for the weekly reports email currently underway is {context}') # render the template with the data html_content = render_to_string('vince/printweeklyreport.html', context) + "" @@ -2740,6 +2781,13 @@ def prepare_and_send_weekly_report(): # send it to mailer.py for final pre-processing send_weekly_report_mail(recipients, my_team, html_content) +def prepare_and_send_alert_email(cr): + logger.debug('prepare_and_send_alert_email has been correctly triggered') + recipients = ['gstrom@cert.org', 'gstrom@cert.org'] + crlink = f"{settings.SERVER_NAME}{reverse('vince:cr', args=[cr.id])}" + logger.debug(f'crlink is {crlink}') + send_alert_email(recipients, crlink) + def send_vt_daily_digest(user): text = "" diff --git a/vince/mailer.py b/vince/mailer.py index a2913ac..9b22d8e 100644 --- a/vince/mailer.py +++ b/vince/mailer.py @@ -724,6 +724,8 @@ def send_templated_mail(template_name, bcc = list(set(bcc) - set(bouncers)) recipients = list(set(recipients) - set(bouncers)) except Exception as e: + # Note: if the following error turns up in the logs, that does not mean that anything is malfunctioning. It may just mean that the line "bcc = list(set(bcc) - set(bouncers))" + # above failed because no bouncers were found, which is what is supposed to happen. logger.debug(f"Could not execute query against bounce list error {e}") if replyto: @@ -1213,6 +1215,24 @@ def send_weekly_report_mail(recipients, my_team, html_content): html=True ) +def send_alert_email(recipients, crlink): + subject = 'VINCE Alert' + + context = { + 'crlink': crlink, + 'subject': subject, + 'login_url': f"{settings.SERVER_NAME}" + } + + send_templated_mail( + 'ai_ml_system_email', + context, + recipients=recipients, + fail_silently=True, + files=None, + html=True + ) + def send_email_to_all(to_group, subject, content, from_user, ticket): if to_group == '1': # get all vendors = get all groups with contacts diff --git a/vince/models.py b/vince/models.py index b13aa43..fac0a47 100644 --- a/vince/models.py +++ b/vince/models.py @@ -4618,3 +4618,26 @@ class BounceEmailNotification(models.Model): default = False) +class VinceAlerts(models.Model): + unique_id = models.AutoField( + primary_key=True, + ) + + trigger = models.TextField( + blank=False, + null=False, + default='CaseRequest.metadata.ai_ml_system', + unique=True, + ) + + alert_recipients = models.TextField( + blank=False, + null=False, + ) + + metadata = OldJSONField( + # This is where someday we will add "alert_type", which has the information about whether the email should include all info from the vul report or just a link + # or maybe something else + blank=True, + null=True, + ) \ No newline at end of file diff --git a/vince/static/vince/css/style.css b/vince/static/vince/css/style.css index ac3173b..3058b19 100644 --- a/vince/static/vince/css/style.css +++ b/vince/static/vince/css/style.css @@ -140,7 +140,7 @@ h1.vince_login_logo { font-size: .5em; /*whatever em or rem size needed to get to this*/ font-weight: 700; } - + .site-wrapper { /* display: flex; flex-direction: column;*/ diff --git a/vince/static/vince/js/case.js b/vince/static/vince/js/case.js index db04588..f03d42e 100644 --- a/vince/static/vince/js/case.js +++ b/vince/static/vince/js/case.js @@ -85,10 +85,26 @@ function reloadVendorStats(case_id) { }}); } +function ajaxVendorData(tablet){ + $.ajax({ + url: "/vince/ajax_calls/case/vendors/"+$(".case-container").attr('caseid')+"/", + type: "GET", + success: function(data) { + tablet.setData(data['data']); + return data['data'] + }, + error: function() { + permissionDenied(addmodal); + } + }); +} +function loadVendors(tablet){ + tablet.setData(ajaxVendorData(tablet)) +} function reloadVendors(case_id, tablet) { - tablet.replaceData(); + tablet.replaceData(ajaxVendorData(tablet)); /*$.ajax({ url: "/vince/ajax_calls/case/vendors/"+case_id+"/", success: function(data) { @@ -98,6 +114,17 @@ function reloadVendors(case_id, tablet) { reloadVendorStats(case_id); } +function updateSelectedCount(tablet){ + let selectedRows = tablet.getSelectedRows(); + if (selectedRows.length == 0){ + $("#selected_count").html('') + } else if (selectedRows.length == 1) { + $("#selected_count").html('1 vendor selected') + } else { + $("#selected_count").html(selectedRows.length + ' vendors selected') + } +} + function reloadVuls(case_id, table) { $.ajax({ url: "/vince/ajax_calls/case/vulnerabilities/"+case_id+"/", @@ -136,7 +163,7 @@ function reloadParticipants(case_id, tablet) { } function reload_case_activity() { - var url = $("#case_activity").attr("href"); + var url = $("#timeline").attr("href"); $.ajax({ url: url, success: function(data) { @@ -682,8 +709,7 @@ $(document).ready(function() { "[0] of " + "2 "); while (i <= max) { - url = 'https://vince.cert.org/vince/ajax_calls/case/vendors/'+ - caseid+'/?page='+String(i); + url = 'https://vince.cert.org/vince/ajax_calls/case/vendors/' + caseid+'/?page='+String(i); hm.find('.cpage').html(String(i)); await $.get(url,function(d) { if(d.last_page) @@ -738,6 +764,7 @@ $(document).ready(function() { } } hm.append(""); + updateSelectedCount(vendors_table); finish_modal(hm); } @@ -794,6 +821,7 @@ $(document).ready(function() { } }); + updateSelectedCount(vendors_table) finish_modal(hm); } @@ -1401,54 +1429,90 @@ $(document).ready(function() { var vendors_table = new Tabulator("#vendors-table", { //data:vendors_data, //set initial table data - data:[], + data:[], layout:"fitColumns", - selectable:true, - ajaxURL: "/vince/ajax_calls/case/vendors/"+$(".case-container").attr('caseid')+"/", - ajaxProgressiveLoad:"scroll", - ajaxFiltering:true, - ajaxLoaderLoading: "
Loading Data
", - tooltipsHeader:true, - placeholder: "No vendors.", - selectableCheck:function(row){ + selectable:true, + // ajaxURL: "/vince/ajax_calls/case/vendors/"+$(".case-container").attr('caseid')+"/", + // ajaxProgressiveLoad:"scroll", + pagination: "local", + paginationSize:20, + rowClick:function(e, row){ + updateSelectedCount(vendors_table); + }, + ajaxFiltering:true, + ajaxLoaderLoading: "
Loading Data
", + tooltipsHeader:true, + placeholder: "No vendors.", + selectableCheck:function(row){ //row - row component return row.getData().tagged == false; //allow selection of untagged rows - }, + }, columns:[ {title:"Vendor", field:"vendor", formatter:contactClickFunction, tooltip:vendorToolTipFunction, width:200, headerFilter:"input"}, {title:"Status", field:"status", formatter: "link", formatterParams:statusClickFunction, headerFilter:"input"}, - {titleFormatter:vendornotifiedFormatterFunction, field:"contact_date", editor:dateEditor, cellEdited: function(cell) { + {titleFormatter:vendornotifiedFormatterFunction, field:"contact_date", editor:dateEditor, cellEdited: function(cell) { var csrftoken = getCookie('csrftoken'); $.post(cell.getRow().getData().edit_date_url, - {'csrfmiddlewaretoken': csrftoken, 'new_date':cell.getRow().getData().contact_date}, - function(data) { - approvemodal.html(data).foundation('open'); - }); - }}, + {'csrfmiddlewaretoken': csrftoken, 'new_date':cell.getRow().getData().contact_date}, + function(data) { + approvemodal.html(data).foundation('open'); + }); + }}, {title:"Seen", field:"seen", formatter: eyeFormatter, width:100}, {title:"Approved", field:"user_approved", formatter: appFormatter}, {title:"Statement", field:"statement", formatter:stmtFormatter}, - {title:"Emails", field:"vendor_notification", formatter: vendorNotificationFormatter}, + {title:"Emails", field:"vendor_notification", formatter: vendorNotificationFormatter}, ], }); + loadVendors(vendors_table); + reloadVendorStats($(".case-container").attr('caseid')); + + //select row on "select page" button click + $('#select-page').click(function(){ + let rows = vendors_table.getRows(true); // returns array of all the currently filtered/sorted elements + let currentPage = vendors_table.getPage(); // returns the current page + let pageSize = vendors_table.getPageSize(); // returns the current number of items per page + let start = (currentPage - 1) * pageSize; //start index + // figure out end index, taking into account last page may not be full + if (rows.length < currentPage * pageSize) { + end = start + (rows.length % pageSize); + } else { + end = start + pageSize; + } + + for (let index = start; index < end; index++) { + rows[index].select(); + } + // vendors_table.selectRow("visible"); + let selectedRows = vendors_table.getSelectedRows(); + for (i=0; i < selectedRows.length; i++) { + if (selectedRows[i].getData().tagged) { + vendors_table.deselectRow(selectedRows[i]); + } + } + updateSelectedCount(vendors_table); + }); + //select row on "select all" button click $("#select-all-vendors").click(function(){ - /*vendors_table.selectRow("visible");*/ - vendors_table.selectRow('active'); - var selectedRows = vendors_table.getSelectedRows(); + /*vendors_table.selectRow("visible");*/ + vendors_table.selectRow('active'); + var selectedRows = vendors_table.getSelectedRows(); for (i=0; i < selectedRows.length; i++) { - if (selectedRows[i].getData().tagged) { - vendors_table.deselectRow(selectedRows[i]); - } - } + if (selectedRows[i].getData().tagged) { + vendors_table.deselectRow(selectedRows[i]); + } + } + updateSelectedCount(vendors_table); }); //deselect row on "deselect all" button click $("#deselect-all-vendors").click(function(){ - vendors_table.deselectRow(); + vendors_table.deselectRow(); + updateSelectedCount(vendors_table); }); var flag = false; @@ -1476,6 +1540,7 @@ $(document).ready(function() { // Do something like hide waiting images, or any else function call console.log("Done removing vendors"); reloadVendors($(".case-container").attr('caseid'), vendors_table); + updateSelectedCount(vendors_table); } }; @@ -1602,15 +1667,15 @@ $(document).ready(function() { }); $(document).on("submit", "#case-edit-form", function(event) { - event.preventDefault(); - $.post($(this).attr("action"), $(this).serializeArray(), - function(data) { - reload_case_activity(); - }) + event.preventDefault(); + $.post($(this).attr("action"), $(this).serializeArray(), + function(data) { + reload_case_activity(); + }) .fail(function(d) { - permissionDenied(addmodal); - }); - approvemodal.foundation('close'); + permissionDenied(addmodal); + }); + approvemodal.foundation('close'); }); @@ -1872,9 +1937,9 @@ $(document).ready(function() { function initialize_vuls_tab() { if (document.getElementById('vuls_data')) { - console.log('there is a vuls_data element.') + // console.log('there is a vuls_data element.') var data = JSON.parse(document.getElementById('vuls_data').textContent); - console.log(data); + // console.log(data); if (data) { var table = new Tabulator("#vuls-table", { data:data, diff --git a/vince/static/vince/js/vince.js b/vince/static/vince/js/vince.js index 6e5cb72..7778b04 100644 --- a/vince/static/vince/js/vince.js +++ b/vince/static/vince/js/vince.js @@ -930,4 +930,104 @@ $(function () { console.log(arguments); }); } + + let addmodal = $("#smallmodal"); + + function auto(data, taggle, tag_url, alert_name, modal) { + // This sets up the dropdown where you can select options as you type. + var container = taggle.getContainer(); + var input = taggle.getInput(); + $(input).autocomplete({ + source: data, + appendTo: container, + position: { at: "left bottom", of: container }, + select: function(event, data) { + event.preventDefault(); + if (event.which === 1) { + taggle.add(data.item.value); + var csrftoken = getCookie('csrftoken'); + $.post(tag_url, {'state': 1, 'alert': alert_name, 'add_tag': 1, 'csrfmiddlewaretoken': csrftoken, 'tag':data.item.value }, function(d) { + }) + .fail(function(d) { + alert("An error occurred while trying to add this tag."); + taggle.remove(data.item.value); + }); + } + } + }); + } + + + if (document.getElementsByClassName("alert-taggle")) { + let tag_url = ''; + let alert_taggle_divs = document.getElementsByClassName("alert-taggle"); + let alert_name = ''; + let alert_recipients_div = ''; + for (let i = 0; i < alert_taggle_divs.length; i++){ + // get the alert name + alert_name = alert_taggle_divs[i].parentElement.id; + alert_recipients_div = alert_taggle_divs[i].id; + tag_url = $("#" + alert_recipients_div).attr("href"); + // create a new taggle + let taggle = new Taggle(alert_recipients_div, { + tags: ['gstrom@cert.org'], + duplicateTagClass: 'bounce', + preserveCase: true, + allowedTags: ['gstrom@cert.org', 'gstrom@sei.cmu.edu'], + // this tagFormatter business will be useful once we're actually receiving our desired data from a database: + // tagFormatter: function(li) { + // spanElement = li.querySelector('.taggle_text') + // inputElement = li.querySelector('input') + // inputElementValue = li.querySelector('input').value + // // find the element whose value = the current input, then change the spanElement innerHTML to the label in that element. + // let objectWithThisEmail = assignable_ordered_pairs.filter(function checkit(object){ + // if (object['value'] == inputElement.value){ + // return object + // } + // })[0] + // spanElement.setAttribute('title', spanElement.innerHTML) + // spanElement.innerHTML = objectWithThisEmail['label'] + // return li; + // }, + onTagAdd: function(event, tag) { + // to be clear, tag is the value of the input element, not the text in the span element. + if (event) { + let csrftoken = getCookie('csrftoken'); + $.post(tag_url, {'state': 1, 'alert': alert_name, 'csrfmiddlewaretoken': csrftoken, 'tag':tag }, function(data) { + }) + .fail(function(data) { + permissionDenied(addmodal); + taggle.remove(tag); + }); + } + }, + onBeforeTagRemove: function(event, tag) { + // to be clear, tag is the value of the input element, not the text in the span element. + if (event) { + let csrftoken = getCookie('csrftoken'); + let jqxhr = $.post(tag_url, {'state': 0, 'alert': alert_name, 'csrfmiddlewaretoken': csrftoken, 'tag':tag}, function(data) { + }) + .fail(function(data) { + permissionDenied(addmodal); + taggle.add(tag); + }); + } + return true; + }, + }) + + let assignable = ['gstrom@cert.org', 'gstrom@sei.cmu.edu'] + + auto(assignable, taggle, tag_url, alert_name, addmodal); + } + + // let assignable_ordered_pairs_with_emails = assignable_ordered_pairs.map(function(originalOrderedPair){ + // newOrderedPair = {} + // newOrderedPair['label'] = originalOrderedPair['label'] + ' [' + originalOrderedPair['value'] + ']' + // newOrderedPair['value'] = originalOrderedPair['value'] + // return newOrderedPair + // }) + } + + }); diff --git a/vince/templates/vince/base.html b/vince/templates/vince/base.html new file mode 100644 index 0000000..f73c812 --- /dev/null +++ b/vince/templates/vince/base.html @@ -0,0 +1,324 @@ + + + + + + + + + + + + {% block extra_head_tags %} + + + VINCETrack + {% endblock %} + + {% load static %} + + + + + + {##} + + + + + + + {% block js %} + + + + + {##} + {##} + {##} + + {% endblock %} + + + + +
+
+
+ +
+
+ + + + +
+ + +
+
+ {% block content %} + {% endblock %} + +
+ + +
+
+ +
+ + + + diff --git a/vince/templates/vince/case.html b/vince/templates/vince/case.html index fe86fb0..de672ba 100644 --- a/vince/templates/vince/case.html +++ b/vince/templates/vince/case.html @@ -94,9 +94,22 @@

Artifacts

-
- +
+
+
+

{% trans "Activity" %}

+
+
{% include 'vince/include/case_activity.html' %} +
+
+
+
+ {% include 'vince/include/case_timeline.html' %} +
+
+
+
diff --git a/vince/templates/vince/case_summary.html b/vince/templates/vince/case_summary.html index f2311d9..f8b98dd 100644 --- a/vince/templates/vince/case_summary.html +++ b/vince/templates/vince/case_summary.html @@ -13,7 +13,7 @@ {% endif %}
  • Tasks
  • Vuls
  • -
  • Vendors
  • +
  • Vendors
  • Posts
  • Vul Note
  • Participants
  • @@ -168,6 +168,7 @@
    + @@ -180,6 +181,7 @@ Start Group Thread {% endif %}
    +
    {% if vendorgroups %} {% for g in vendorgroups %} diff --git a/vince/templates/vince/include/case_activity.html b/vince/templates/vince/include/case_activity.html index 734e1b0..2098ff0 100644 --- a/vince/templates/vince/include/case_activity.html +++ b/vince/templates/vince/include/case_activity.html @@ -1,11 +1,5 @@ {% load i18n humanize %} {% load widget_tweaks %} -
    -
    -

    {% trans "Activity" %}

    -
    -
    -
    {% csrf_token %}
    @@ -124,13 +118,5 @@

    {% trans "Activity" %}

    {% endif %}
    -
    -
    -
    -
    - {% include 'vince/include/case_timeline.html' %} -
    -
    -
    -
    + diff --git a/vince/templates/vince/manage_alerts.html b/vince/templates/vince/manage_alerts.html new file mode 100644 index 0000000..d6c4447 --- /dev/null +++ b/vince/templates/vince/manage_alerts.html @@ -0,0 +1,47 @@ +{% extends VINCETRACK_BASE_TEMPLATE %}{% load i18n dashboard_tags %} + +{% block vince_title %}{% trans "Manage Alerts" %}{% endblock %} +{% load staticfiles %} +{% block content %} + +
    +
    +
    +

    Manage Alerts

    +
    +
    + +
    +
    +
    + +{{ alertsjs|json_script:"alerts_data" }} + + + + + {% for alert in alerts %} + + + + + + + + + + + +

    + + {% endfor %} + + +
    {% trans alert.label %}{% trans alert.help_text %}
    {% trans "Recipients" %} +
    +
    + + +
    + +{% endblock %} \ No newline at end of file diff --git a/vince/templates/vince/printweeklyreport.html b/vince/templates/vince/printweeklyreport.html index 7cd2afe..6ca81ab 100644 --- a/vince/templates/vince/printweeklyreport.html +++ b/vince/templates/vince/printweeklyreport.html @@ -237,6 +237,9 @@

    Tickets Opened

    {% endfor %} {% endif %} + + + {% if total_closed %}

    Tickets Closed

    @@ -251,6 +254,32 @@

    Tickets Closed

    {% endfor %}
    {% endif %} + + {% if my_team.name == "CERT/CC" %} + {% if ai_ml_boolean %} +

    AI/ML Activity

    + + + + + + + + + + + + + + + + +
    AI/ML Case Requests{{ total_ai_ml_crs }}
    New AI/ML Cases{{ ai_ml_case_stats.ai_ml_new_cases}}
    Pre-existing Active AI/ML Cases{{ ai_ml_case_stats.ai_ml_active_cases}}
    Deactivated AI/ML Cases{{ ai_ml_case_stats.ai_ml_deactive_cases}}
    Reactivated AI/ML Cases{{ ai_ml_case_stats.ai_ml_to_active_cases}}
    + {% else %} +

    No AI/ML Activity

    + {% endif %} + {% endif %} +

    Users

    diff --git a/vince/urls.py b/vince/urls.py index 38d3e60..5585ceb 100644 --- a/vince/urls.py +++ b/vince/urls.py @@ -59,6 +59,7 @@ path('user/admin/', views.VinceUserAdminView.as_view(), name='useradmin'), path('user/admin/contacts/reports/', views.VinceContactReportsView.as_view(), name='contactreports'), re_path(r'^user/admin/contacts/reports/(?P[1-5])/$', views.VinceContactReportsView.as_view(), name='contactreports'), + # path('user/admin/notifications', views.ManageAlertsView.as_view(), name='managenotifications'), path('roles/', views.TriageRoleView.as_view(), name='roles'), path('tags/', views.VinceTagManagerView.as_view(), name='tags'), re_path(r'^tags/team/(?P[0-9]+)/$', views.VinceTagManagerView.as_view(), name='tags'), @@ -209,6 +210,7 @@ re_path('^editcasevendors/(?P[0-9]+)/$', views.EditVendorCaseList.as_view(), name='editvendorlist'), re_path('^rmpart/(?P[0-9]+)/$', views.RemoveParticipantFromCase.as_view(), name='rmpartnoconfirm'), re_path('^removeparticipant/(?P[0-9]+)/$', views.ConfirmRemoveParticipant.as_view(), name='rmparticipant'), + re_path('^taguser/$', views.TagUser.as_view(), name='taguser'), re_path('^taguser/(?P[0-9]+)/$', views.TagUser.as_view(), name='taguser'), re_path('^newcr/(?P[0-9]+)/$', views.CreateNewCaseRequestView.as_view(), name='newcr'), re_path('^newcr/case/(?P[0-9]+)/$', views.CreateNewCaseRequestView.as_view(), name='newcrcase'), @@ -224,7 +226,7 @@ re_path('^case/(?P[0-9]+)/artifact/edit/$', views.AddCaseArtifactView.as_view(), name='editcase_artifacts'), re_path('artifact/(?P[0-9]+)/edit/$', views.EditArtifactView.as_view(), name='editartifact'), re_path('^ticket/(?P[0-9]+)/$', views.TicketView.as_view(), name='ticket'), - re_path('^case/(?P[0-9]+)/activity/$', views.CaseActivityView.as_view(), name='case_activity'), + re_path('^case/(?P[0-9]+)/timeline/$', views.CaseTimelineView.as_view(), name='case_timeline'), re_path('^case/(?P[0-9]+)/$', views.CaseView.as_view(), name='case'), re_path('^case/(?P[0-9]+)/tickets/$', views.CaseTicketView.as_view(), name='casetickets'), re_path('^case/(?P[0-9]+)/posts/$', views.CasePostsView.as_view(), name='casepoststab'), diff --git a/vince/views.py b/vince/views.py index 85d50b0..cc69cfe 100644 --- a/vince/views.py +++ b/vince/views.py @@ -544,8 +544,10 @@ def autocomplete_casevendors(request, pk): paginator = Paginator(vendors, size) - vendorsjs = [obj.as_dict() for obj in paginator.page(page)] - #vendorsjs = [obj.as_dict() for obj in vendors] + # Until Nov 8 2023, we had this pagination calculated on the backend. Now, we calculate it using Tabulator ('pagination: "local"') in vince/case.js. + # With corresponding changes in case.js, this can be reversed by switching back to vendorsjs = [obj.as_dict() for obj in paginator.page(page)] here. + # vendorsjs = [obj.as_dict() for obj in paginator.page(page)] + vendorsjs = [obj.as_dict() for obj in vendors] alert_tags = list(TagManager.objects.filter(tag_type=2, alert_on_add=True).values_list('tag', flat=True)) logger.debug(f"ALERT TAGS: {alert_tags}") @@ -1613,7 +1615,6 @@ def get(self, request, *args, **kwargs): my_queues = get_r_queues(self.request.user) if facet == "All": - logger.debug('VINCE thinks we are searching All') ticket_results = Ticket.objects.search(search_query).filter(queue__in=my_queues) tkttags = TicketTag.objects.filter(ticket__queue__in=my_queues, tag__in=search_tags).values_list('ticket__id', flat=True) if ticket_results and tkttags: @@ -1662,7 +1663,6 @@ def get(self, request, *args, **kwargs): elif facet == "Tickets": - logger.debug('VINCE thinks we are searching Tickets') ticket_results = Ticket.objects.search(search_query).filter(queue__in=my_queues) tkttags = TicketTag.objects.filter(ticket__queue__in=my_queues, tag__in=search_tags).values_list('ticket__id', flat=True) if ticket_results and tkttags: @@ -1674,7 +1674,6 @@ def get(self, request, *args, **kwargs): activities = Ticket.objects.filter(id__in=activity_results) ticket_results = ticket_results | tkt_title | activities elif facet == "Contacts": - logger.debug('VINCE thinks we are searching Contacts') vince_user_results = VinceProfile.objects.using('vincecomm').filter(Q(user__first_name__icontains=search_term) | Q(user__last_name__icontains=search_term) | Q(preferred_username__icontains=search_term) | Q(user__email__icontains=search_term)) user_contacts = list(vince_user_results.values_list('user__email', flat=True)) email_contacts = EmailContact.objects.filter(contact__vendor_type="Contact", email__in=user_contacts).values_list('contact__id', flat=True) @@ -1690,7 +1689,6 @@ def get(self, request, *args, **kwargs): group_results = ContactGroup.objects.filter(Q(name__icontains=search_term) | Q(srmail_peer_name__icontains=search_term)) elif facet == "Cases": - logger.debug('VINCE thinks we are searching Cases') case_results = VulnerabilityCase.objects.search(search_query) casetags = CaseTag.objects.filter(tag__in=search_tags).values_list('case__id', flat=True) if case_results and casetags: @@ -1707,7 +1705,6 @@ def get(self, request, *args, **kwargs): case_results = case_results | vnote_cases elif facet == "Vuls": - logger.debug('VINCE thinks we are searching Vuls') vul_results = Vulnerability.objects.search(search_query) cve_results = CVEAllocation.objects.extra(where=["search_vector @@ (to_tsquery('english', %s))=true"], params=[search_query]) @@ -1716,7 +1713,6 @@ def get(self, request, *args, **kwargs): vultags = Vulnerability.objects.filter(id__in=vultags) vul_results = vul_results | vultags elif facet == "Users": - logger.debug('VINCE thinks we are searching Users') vince_user_results = VinceProfile.objects.using('vincecomm').filter(Q(user__first_name__icontains=search_term) | Q(user__last_name__icontains=search_term) | Q(preferred_username__icontains=search_term) | Q(user__email__icontains=search_term)) if tktsearch: @@ -2714,7 +2710,7 @@ def form_valid(self, form): ticket = form.save(user=self.request.user) queue = TicketQueue.objects.get(id=form.cleaned_data['queue']) - if queue.queue_type == CASE_REQUEST_QUEUE: + if queue.queue_type == TicketQueue.CASE_REQUEST_QUEUE: return redirect("vince:newcr", ticket.id) return HttpResponseRedirect(ticket.get_absolute_url()) @@ -3689,6 +3685,10 @@ def get_context_data(self, **kwargs): context['case'] = review.vulnote.vulnote.case context['review'] = review #get reviews for this ticket: + if not review.date_complete or not review.complete: + review.complete = True + review.date_complete = review.ticket.modified + review.save() if review.ticket: context['reviews'] = VulNoteReview.objects.filter(ticket=review.ticket, complete=True, date_complete__lte=review.date_complete).exclude(id=review.id).order_by('-date_complete') if context['reviews']: @@ -4352,6 +4352,8 @@ def post(self, request, *args, **kwargs): def get_context_data(self, **kwargs): context = super(CaseRequestView, self).get_context_data(**kwargs) context['ticket'] = get_object_or_404(CaseRequest, id=self.kwargs['pk']) + logger.debug("context['ticket'] is") + logger.debug(context['ticket']) if deepGet(context["ticket"].metadata,"ai_ml_system") == True: context['ai_ml_system'] = True else: @@ -5229,7 +5231,7 @@ def get_context_data(self, **kwargs): return context -class CaseActivityView(LoginRequiredMixin, TokenMixin, UserPassesTestMixin, generic.TemplateView): +class CaseTimelineView(LoginRequiredMixin, TokenMixin, UserPassesTestMixin, generic.TemplateView): login_url = "vince:login" template_name = "vince/include/case_timeline.html" @@ -5241,7 +5243,7 @@ def test_func(self): return False def get_context_data(self, **kwargs): - context = super(CaseActivityView, self).get_context_data(**kwargs) + context = super(CaseTimelineView, self).get_context_data(**kwargs) context['case'] = get_object_or_404(VulnerabilityCase, id=self.kwargs['pk']) ca = Action.objects.select_related('caseaction').filter(caseaction__case=context['case']) ta = Action.objects.select_related('followup').filter(followup__ticket__case=context['case']) @@ -5959,12 +5961,12 @@ def get_context_data(self, **kwargs): # vulnote doesn't exist pass - context['vuls'] = Vulnerability.casevuls(context['case']) - context['vulsjs'] = [obj.as_dict() for obj in context['vuls']] + # context['vuls'] = Vulnerability.casevuls(context['case']) + # context['vulsjs'] = [obj.as_dict() for obj in context['vuls']] context['vendors'] = VulnerableVendor.casevendors(context['case']).order_by('contact__vendor_name') - context['vendorgroups'] = VulnerableVendor.casevendors(context['case']).exclude(from_group__isnull=True).distinct('from_group') - context['participants'] = CaseParticipant.objects.filter(case=context['case']).order_by('user_name') - context['participantsjs'] = [obj.as_dict() for obj in context['participants']] + # context['vendorgroups'] = VulnerableVendor.casevendors(context['case']).exclude(from_group__isnull=True).distinct('from_group') + # context['participants'] = CaseParticipant.objects.filter(case=context['case']).order_by('user_name') + # context['participantsjs'] = [obj.as_dict() for obj in context['participants']] vc_case = Case.objects.filter(vince_id=context['case'].id).first() if vc_case: @@ -5978,7 +5980,7 @@ def get_context_data(self, **kwargs): key=lambda x: normalize_time(x,'created'), reverse=True)[:10] - # this is all done asych now... + # this is all done async now... vc_case_participants = CaseMember.objects.filter(case=vc_case, participant__isnull=False) form = CaseCommunicationsFilterForm() form.fields['vendor'].choices = [ @@ -6355,8 +6357,15 @@ class TagUser(LoginRequiredMixin, TokenMixin, UserPassesTestMixin, generic.Templ def test_func(self): if is_in_group_vincetrack(self.request.user): - case = get_object_or_404(VulnerabilityCase, id=self.kwargs['case_id']) - return has_case_write_access(self.request.user, case) + if self.kwargs.get('case_id'): + case = get_object_or_404(VulnerabilityCase, id=self.kwargs['case_id']) + logger.debug(f"self.kwargs['case_id'] is {self.kwargs['case_id']}") + return has_case_write_access(self.request.user, case) + else: + if self.request.POST.get('alert'): + return self.request.user.is_superuser + else: + pass else: return False @@ -6370,62 +6379,68 @@ def get(self, request, *args, **kwargs): return JsonResponse({'response': 'success', 'case_assigned_to': assignments, 'assignable_users': assignable_users}, status=200) def post(self, request, *args, **kwargs): - case = get_object_or_404(VulnerabilityCase, id=self.kwargs['case_id']) - logger.debug(f"{self.__class__.__name__} post: {request.POST}") - if (int(request.POST['state']) == 1): - #get case - user = User.objects.filter(email=request.POST['tag']).first() - if user: - ca = CaseAction(case=case, - title=f"User {user.usersettings.preferred_username} assigned to case", - user=self.request.user, action_type=CaseAction.lookup('VinceTrack')) - ca.save() - #do this after the case action, so the assignee doesn't get 2 emails - assignment = CaseAssignment(assigned=user, - case=case) - assignment.save() - else: - logger.debug(f"there was an error when trying to add {request.POST['tag']} to the case") - return JsonResponse({'error': 'User does not exist'}, status=401) - - #is this user a part of one of the caseparticipants - otherwise add them - contacts = user.groups.exclude(groupsettings__contact__isnull=True).values_list('groupsettings__contact__id', flat=True) - cps = CaseParticipant.objects.filter(case=case).filter(Q(user_name=user.email)|Q(contact__in=contacts)) - logger.debug(f"Checking Case participants for {case} which is {cps}") - if not cps: - #this user isn't a part of any of the CaseParticipants, so add them now - cp, created = CaseParticipant.objects.update_or_create(case=case, - user_name=user.email, - defaults = {'coordinator':True, - 'added_by':self.request.user, - 'added_to_case':timezone.now(), - 'status':"Notified"}) - add_participant_vinny_case(case, cp) - - - send_updatecase_mail(ca, user) - - else: - # delete user from case - user = User.objects.filter(email=request.POST['tag']).first() - if user: - ca = CaseAction(case=case, - title=f"User {user.usersettings.preferred_username} removed from case assignment", - user=self.request.user, action_type=CaseAction.lookup('VinceTrack')) - ca.save() - assignment = CaseAssignment.objects.filter(assigned=user, case=case) - if assignment: - assignment.delete() + if self.kwargs.get('case_id'): + case = get_object_or_404(VulnerabilityCase, id=self.kwargs['case_id']) + logger.debug(f"{self.__class__.__name__} post: {request.POST}") + if (int(request.POST['state']) == 1): + #get case + user = User.objects.filter(email=request.POST['tag']).first() + if user: + ca = CaseAction(case=case, + title=f"User {user.usersettings.preferred_username} assigned to case", + user=self.request.user, action_type=CaseAction.lookup('VinceTrack')) + ca.save() + #do this after the case action, so the assignee doesn't get 2 emails + assignment = CaseAssignment(assigned=user, + case=case) + assignment.save() + else: + logger.debug(f"there was an error when trying to add {request.POST['tag']} to the case") + return JsonResponse({'error': 'User does not exist'}, status=401) + + #is this user a part of one of the caseparticipants - otherwise add them + contacts = user.groups.exclude(groupsettings__contact__isnull=True).values_list('groupsettings__contact__id', flat=True) + cps = CaseParticipant.objects.filter(case=case).filter(Q(user_name=user.email)|Q(contact__in=contacts)) + logger.debug(f"Checking Case participants for {case} which is {cps}") + if not cps: + #this user isn't a part of any of the CaseParticipants, so add them now + cp, created = CaseParticipant.objects.update_or_create(case=case, + user_name=user.email, + defaults = {'coordinator':True, + 'added_by':self.request.user, + 'added_to_case':timezone.now(), + 'status':"Notified"}) + add_participant_vinny_case(case, cp) + + + send_updatecase_mail(ca, user) + else: - return JsonResponse({'error': 'User does not exist'}, status=401) + # delete user from case + user = User.objects.filter(email=request.POST['tag']).first() + if user: + ca = CaseAction(case=case, + title=f"User {user.usersettings.preferred_username} removed from case assignment", + user=self.request.user, action_type=CaseAction.lookup('VinceTrack')) + ca.save() + assignment = CaseAssignment.objects.filter(assigned=user, case=case) + if assignment: + assignment.delete() + else: + return JsonResponse({'error': 'User does not exist'}, status=401) - #was this user a one off assignment (ie not a part of the team of coordinators? - cp = CaseParticipant.objects.filter(case=case, user_name=user.email, coordinator=True).first() - if cp: - remove_participant_vinny_case(case, cp) - cp.delete() + #was this user a one off assignment (ie not a part of the team of coordinators? + cp = CaseParticipant.objects.filter(case=case, user_name=user.email, coordinator=True).first() + if cp: + remove_participant_vinny_case(case, cp) + cp.delete() + + return JsonResponse({'response': 'success'}, status=200) + else: + if request.POST.get('alert'): + logger.debug(f"There is an alert value and it is {request.POST.get('alert')}") + return JsonResponse({'response': 'success'}, status=200) - return JsonResponse({'response': 'success'}, status=200) class CaseParticipantsView(LoginRequiredMixin, TokenMixin, UserPassesTestMixin, generic.TemplateView): login_url = 'vince:login' @@ -6510,7 +6525,13 @@ def get_context_data(self, **kwargs): else: context['ticket'] = context['case'].case_request if hasattr(context['ticket'],'vrf_id') and context['ticket'].vrf_id: - context['vrf_url'] = download_vrf(context['ticket'].vrf_id) + context['vrf_url'] = download_vrf(context['ticket'].vrf_id) + + # if deepGet(context["ticket"].metadata,"ai_ml_system") == True: + # context['ai_ml_system'] = True + # else: + # context['ai_ml_system'] = False + context['table_class'] = "hover unstriped" context['noshowdeps'] = 1 context['assignable'] = [ u.usersettings.preferred_username for u in User.objects.filter(is_active=True, groups__name='vince')] @@ -12570,7 +12591,7 @@ def form_valid(self, form): if self.request.META.get('HTTP_REFERER') and is_safe_url(self.request.META.get('HTTP_REFERER'),set(settings.ALLOWED_HOSTS),True): return HttpResponseRedirect(self.request.META.get('HTTP_REFERER')+"#vuls") - return redirect("vince:editvuls", case.id) + return redirect('vince:case', case.id) def form_invalid(self, form): logger.debug(f"{self.__class__.__name__} errors: {form.errors}") @@ -14244,6 +14265,38 @@ def get_context_data(self, **kwargs): return context +# This is a rough draft of a view for managing alert emails that would be sent to different groups of people under various circumstances: +# +# class ManageAlertsView(LoginRequiredMixin, TokenMixin, UserPassesTestMixin, generic.TemplateView): +# login_url = "vince:login" +# template_name = "vince/manage_alerts.html" + +# def test_func(self): +# return is_in_group_vincetrack(self.request.user) and self.request.user.is_superuser + +# def get_context_data(self, **kwargs): +# context = super(ManageAlertsView, self).get_context_data(**kwargs) +# context['alerts'] = [ +# { +# 'label': 'AI/ML Systems', +# 'name_for_html_id': 'ai_ml_systems', +# 'help_text': 'Recipients of this alert will receive email notifications whenever a vulnerability report related to AI/ML systems is submitted.', +# 'current_recipients': ['gstrom@cert.org'], +# 'eligible_recipients': ['gstrom@cert.org', 'gstrom@sei.cmu.edu'], +# }, +# { +# 'label': 'Rotwang', +# 'name_for_html_id': 'rotwang', +# 'help_text': 'Recipients of this alert will receive email notifications whenever a vulnerability report related to Metropolis is submitted.', +# 'current_recipients': ['gstrom@cert.org'], +# 'eligible_recipients': ['gstrom@cert.org', 'gstrom@sei.cmu.edu'], +# }, + +# ] +# # context['alertsjs'] = [obj.as_dict() for obj in context['alerts']] +# return context + + class CreateNewVinceUserView(LoginRequiredMixin, TokenMixin, UserPassesTestMixin, generic.FormView): login_url = "vince:login" template_name = "vince/create_user.html" diff --git a/vinceworker/views.py b/vinceworker/views.py index b861a60..3bfbd03 100644 --- a/vinceworker/views.py +++ b/vinceworker/views.py @@ -32,7 +32,7 @@ from django.shortcuts import render, get_object_or_404 import json from vince.apps import VinceTrackConfig -from vince.models import VinceSQS, TicketQueue, FollowUp, CaseRequest, AdminPGPEmail, Contact, ContactGroup, EmailContact, PhoneContact, ContactPgP, GroupMember, VendorNotification, VTDailyNotification, GroupSettings +from vince.models import VinceSQS, TicketQueue, FollowUp, CaseRequest, AdminPGPEmail, Contact, ContactGroup, EmailContact, PhoneContact, ContactPgP, GroupMember, VendorNotification, VTDailyNotification, GroupSettings, VinceAlerts import os import boto3 import traceback @@ -41,7 +41,7 @@ from django.forms.models import model_to_dict from vince.forms import TicketForm, CreateCaseRequestForm -from vince.lib import update_vendor_status, update_vendor_view_status, create_ticket, create_case_post_action, create_action, process_s3_download, add_case_artifact, update_case_request, create_ticket_from_email_s3, update_vendor_status_statement, create_bounce_ticket, send_vt_daily_digest, generate_vt_reminders, reset_user_mfa, prepare_and_send_weekly_report +from vince.lib import update_vendor_status, update_vendor_view_status, create_ticket, create_case_post_action, create_action, process_s3_download, add_case_artifact, update_case_request, create_ticket_from_email_s3, update_vendor_status_statement, create_bounce_ticket, send_vt_daily_digest, generate_vt_reminders, reset_user_mfa, prepare_and_send_weekly_report, prepare_and_send_alert_email from vinny.lib import send_post_email, send_usermention_notification from django.contrib.auth.models import User from vinny.models import PostRevision @@ -77,6 +77,8 @@ def send_sns(vul_id, issue, error): def vince_retrieve_submission(cr, vrf, attachment): + logger.debug('the new cr is') + logger.debug(cr) s3 = boto3.resource('s3', region_name=settings.AWS_REGION) @@ -131,6 +133,12 @@ def vince_retrieve_submission(cr, vrf, attachment): send_newticket_mail(followup=followup, files=None, user=None) + if cr.metadata: + if cr.metadata["ai_ml_system"] and cr.metadata["ai_ml_system"] == True: + logger.debug('the code acknowledges that cr.metadata and cr.metadata["ai_ml_system"] and cr.metadata["ai_ml_system"] == True is true') + if VinceAlerts.objects.filter(trigger="CaseRequest.metadata.ai_ml_system"): + prepare_and_send_alert_email(cr, 'ai_ml_system') + except: logger.debug(traceback.format_exc()) logger.warning("File does not exist") diff --git a/vinny/templates/vinny/base.html b/vinny/templates/vinny/base.html index d1e0429..93890f1 100644 --- a/vinny/templates/vinny/base.html +++ b/vinny/templates/vinny/base.html @@ -94,6 +94,9 @@

    + + +
    {% include 'vinny/offcanvas.html' %}
    @@ -142,7 +145,7 @@

    + + +
    {% include 'vinny/offcanvas.html' %}
    @@ -121,7 +124,7 @@